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
|
|
@ -1,13 +1,25 @@
|
|||
|
||||
import ServiceConfiguration from "@/components/ServiceConfiguration";
|
||||
import ModelConfigurationV2 from "@/components/ModelConfigurationV2";
|
||||
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
|
||||
export default function ServiceConfigurationPage() {
|
||||
interface ServiceConfigurationPageProps {
|
||||
searchParams?: Promise<{
|
||||
action?: string | string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function ServiceConfigurationPage({ searchParams }: ServiceConfigurationPageProps) {
|
||||
const params = searchParams ? await searchParams : {};
|
||||
const action = Array.isArray(params.action) ? params.action[0] : params.action;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<ServiceConfiguration docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides} />
|
||||
<ModelConfigurationV2
|
||||
docsUrl={SETTINGS_DOCUMENTATION_URLS.modelOverrides}
|
||||
initialAction={action}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useEffect,useState } from 'react';
|
|||
import {
|
||||
getDailyReportApiV1OrganizationsReportsDailyGet,
|
||||
getDailyRunsDetailApiV1OrganizationsReportsDailyRunsGet,
|
||||
getPreferencesApiV1OrganizationsPreferencesGet,
|
||||
getWorkflowOptionsApiV1OrganizationsReportsWorkflowsGet
|
||||
} from '@/client/sdk.gen';
|
||||
import type { WorkflowRunDetail } from '@/client/types.gen';
|
||||
|
|
@ -16,7 +17,6 @@ import { Card } from '@/components/ui/card';
|
|||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useUserConfig } from '@/context/UserConfigContext';
|
||||
import { useAuth } from '@/lib/auth';
|
||||
|
||||
import { DispositionChart } from './components/DispositionChart';
|
||||
|
|
@ -57,11 +57,9 @@ export default function ReportsPage() {
|
|||
const [report, setReport] = useState<DailyReport | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { userConfig } = useUserConfig();
|
||||
const [timezone, setTimezone] = useState('America/New_York');
|
||||
const auth = useAuth();
|
||||
|
||||
const timezone = userConfig?.timezone || 'America/New_York';
|
||||
|
||||
// Fetch workflows on mount
|
||||
useEffect(() => {
|
||||
const fetchWorkflows = async () => {
|
||||
|
|
@ -80,6 +78,22 @@ export default function ReportsPage() {
|
|||
fetchWorkflows();
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPreferences = async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
|
||||
try {
|
||||
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
|
||||
if (response.data?.timezone) {
|
||||
setTimezone(response.data.timezone);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch organization preferences:', err);
|
||||
}
|
||||
};
|
||||
fetchPreferences();
|
||||
}, [auth.isAuthenticated]);
|
||||
|
||||
// Fetch report data when date or workflow changes
|
||||
useEffect(() => {
|
||||
const fetchReport = async () => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import { MCPSection } from "@/components/MCPSection";
|
||||
import { OrganizationPreferencesSection } from "@/components/OrganizationPreferencesSection";
|
||||
import { TelemetrySection } from "@/components/TelemetrySection";
|
||||
import {
|
||||
Card,
|
||||
|
|
@ -23,6 +24,19 @@ export default function SettingsPage() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Preferences</CardTitle>
|
||||
<CardDescription>
|
||||
Set organization-wide defaults such as the test phone number and
|
||||
timezone.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<OrganizationPreferencesSection />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP Server</CardTitle>
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ import { useCallback, useEffect, useId, useState } from 'react';
|
|||
import TimezoneSelect, { type ITimezoneOption } from 'react-timezone-select';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getUsageHistoryApiV1OrganizationsUsageRunsGet } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { downloadUsageRunsReportApiV1OrganizationsUsageRunsReportGet, getDailyUsageBreakdownApiV1OrganizationsUsageDailyBreakdownGet, getMpsCreditsApiV1OrganizationsUsageMpsCreditsGet, getPreferencesApiV1OrganizationsPreferencesGet, getUsageHistoryApiV1OrganizationsUsageRunsGet, savePreferencesApiV1OrganizationsPreferencesPut } from '@/client/sdk.gen';
|
||||
import type { DailyUsageBreakdownResponse, MpsCreditsResponse, OrganizationPreferences, UsageHistoryResponse, WorkflowRunUsageResponse } from '@/client/types.gen';
|
||||
import { CallTypeCell } from '@/components/CallTypeCell';
|
||||
import { DailyUsageTable } from '@/components/DailyUsageTable';
|
||||
import { FilterBuilder } from '@/components/filters/FilterBuilder';
|
||||
|
|
@ -36,7 +36,7 @@ const getLocalTimezone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|||
export default function UsagePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { userConfig, saveUserConfig, loading: userConfigLoading, organizationPricing } = useUserConfig();
|
||||
const { organizationPricing } = useUserConfig();
|
||||
const auth = useAuth();
|
||||
|
||||
// MPS credits state
|
||||
|
|
@ -74,6 +74,8 @@ export default function UsagePage() {
|
|||
const localTimezone = getLocalTimezone();
|
||||
const [selectedTimezone, setSelectedTimezone] = useState<ITimezoneOption | string>('');
|
||||
const [savingTimezone, setSavingTimezone] = useState(false);
|
||||
const [preferences, setPreferences] = useState<OrganizationPreferences>({});
|
||||
const [preferencesLoading, setPreferencesLoading] = useState(true);
|
||||
const timezoneSelectId = useId(); // Stable ID for react-select to prevent hydration mismatch
|
||||
|
||||
// Fetch MPS credits
|
||||
|
|
@ -168,6 +170,23 @@ export default function UsagePage() {
|
|||
}
|
||||
}, [auth.isAuthenticated, organizationPricing]);
|
||||
|
||||
const fetchPreferences = useCallback(async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
|
||||
setPreferencesLoading(true);
|
||||
try {
|
||||
const response = await getPreferencesApiV1OrganizationsPreferencesGet();
|
||||
const nextPreferences = response.data || {};
|
||||
setPreferences(nextPreferences);
|
||||
setSelectedTimezone(nextPreferences.timezone || localTimezone);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch organization preferences:', error);
|
||||
setSelectedTimezone(localTimezone);
|
||||
} finally {
|
||||
setPreferencesLoading(false);
|
||||
}
|
||||
}, [auth.isAuthenticated, localTimezone]);
|
||||
|
||||
// Download a CSV of all runs matching the current filters.
|
||||
const handleDownloadReport = async () => {
|
||||
if (!auth.isAuthenticated) return;
|
||||
|
|
@ -203,31 +222,31 @@ export default function UsagePage() {
|
|||
const handleTimezoneChange = async (timezone: ITimezoneOption | string) => {
|
||||
setSelectedTimezone(timezone);
|
||||
setSavingTimezone(true);
|
||||
const previousTimezone = preferences.timezone || localTimezone;
|
||||
try {
|
||||
const tzValue = typeof timezone === 'string' ? timezone : timezone.value;
|
||||
await saveUserConfig({ timezone: tzValue });
|
||||
const response = await savePreferencesApiV1OrganizationsPreferencesPut({
|
||||
body: {
|
||||
...preferences,
|
||||
timezone: tzValue,
|
||||
},
|
||||
});
|
||||
if (response.error) {
|
||||
throw new Error('Failed to save timezone');
|
||||
}
|
||||
setPreferences(response.data || { ...preferences, timezone: tzValue });
|
||||
} catch (error) {
|
||||
console.error('Failed to save timezone:', error);
|
||||
// Revert to previous timezone on error
|
||||
const prevTz = userConfig?.timezone || localTimezone;
|
||||
setSelectedTimezone(prevTz);
|
||||
setSelectedTimezone(previousTimezone);
|
||||
} finally {
|
||||
setSavingTimezone(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Update timezone when userConfig loads
|
||||
// Update timezone when organization preferences load.
|
||||
useEffect(() => {
|
||||
if (!userConfigLoading) {
|
||||
// Config has loaded - set the timezone
|
||||
if (userConfig?.timezone) {
|
||||
setSelectedTimezone(userConfig.timezone);
|
||||
} else {
|
||||
// No saved timezone, use local
|
||||
setSelectedTimezone(localTimezone);
|
||||
}
|
||||
}
|
||||
}, [userConfig, userConfigLoading, localTimezone]);
|
||||
fetchPreferences();
|
||||
}, [fetchPreferences]);
|
||||
|
||||
// Initial load - fetch when auth becomes available
|
||||
useEffect(() => {
|
||||
|
|
@ -340,8 +359,8 @@ export default function UsagePage() {
|
|||
instanceId={timezoneSelectId}
|
||||
value={selectedTimezone}
|
||||
onChange={handleTimezoneChange}
|
||||
isDisabled={savingTimezone || userConfigLoading}
|
||||
placeholder={userConfigLoading ? "Loading..." : "Select timezone"}
|
||||
isDisabled={savingTimezone || preferencesLoading}
|
||||
placeholder={preferencesLoading ? "Loading..." : "Select timezone"}
|
||||
styles={{
|
||||
control: (base, state) => ({
|
||||
...base,
|
||||
|
|
|
|||
|
|
@ -4,15 +4,21 @@ import 'react-international-phone/style.css';
|
|||
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { PhoneInput } from 'react-international-phone';
|
||||
|
||||
import {
|
||||
getPreferencesApiV1OrganizationsPreferencesGet,
|
||||
initiateCallApiV1TelephonyInitiateCallPost,
|
||||
listPhoneNumbersApiV1OrganizationsTelephonyConfigsConfigIdPhoneNumbersGet,
|
||||
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet
|
||||
listTelephonyConfigurationsApiV1OrganizationsTelephonyConfigsGet,
|
||||
savePreferencesApiV1OrganizationsPreferencesPut,
|
||||
} from '@/client/sdk.gen';
|
||||
import type { PhoneNumberResponse, TelephonyConfigurationListItem } from '@/client/types.gen';
|
||||
import type {
|
||||
OrganizationPreferences,
|
||||
PhoneNumberResponse,
|
||||
TelephonyConfigurationListItem,
|
||||
} from '@/client/types.gen';
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -33,6 +39,7 @@ import {
|
|||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useUserConfig } from "@/context/UserConfigContext";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
|
||||
interface PhoneCallDialogProps {
|
||||
open: boolean;
|
||||
|
|
@ -48,21 +55,40 @@ export const PhoneCallDialog = ({
|
|||
user,
|
||||
}: PhoneCallDialogProps) => {
|
||||
const router = useRouter();
|
||||
const { userConfig, saveUserConfig } = useUserConfig();
|
||||
const [phoneNumber, setPhoneNumber] = useState(userConfig?.test_phone_number || "");
|
||||
const { refreshConfig } = useUserConfig();
|
||||
const [preferences, setPreferences] = useState<OrganizationPreferences>({});
|
||||
const [preferencesLoaded, setPreferencesLoaded] = useState(false);
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [callLoading, setCallLoading] = useState(false);
|
||||
const [callError, setCallError] = useState<string | null>(null);
|
||||
const [callSuccessMsg, setCallSuccessMsg] = useState<string | null>(null);
|
||||
const [phoneChanged, setPhoneChanged] = useState(false);
|
||||
const [checkingConfig, setCheckingConfig] = useState(false);
|
||||
const [needsConfiguration, setNeedsConfiguration] = useState<boolean | null>(null);
|
||||
const [sipMode, setSipMode] = useState(() => /^(PJSIP|SIP)\//i.test(userConfig?.test_phone_number || ""));
|
||||
const [sipMode, setSipMode] = useState(false);
|
||||
const [telephonyConfigs, setTelephonyConfigs] = useState<TelephonyConfigurationListItem[]>([]);
|
||||
const [selectedConfigId, setSelectedConfigId] = useState<string>("");
|
||||
const [fromPhoneNumbers, setFromPhoneNumbers] = useState<PhoneNumberResponse[]>([]);
|
||||
const [selectedFromPhoneNumberId, setSelectedFromPhoneNumberId] = useState<string>("");
|
||||
const [loadingPhoneNumbers, setLoadingPhoneNumbers] = useState(false);
|
||||
|
||||
const fetchPreferences = useCallback(async () => {
|
||||
const result =
|
||||
await getPreferencesApiV1OrganizationsPreferencesGet();
|
||||
if (result.error) {
|
||||
throw new Error(detailFromError(result.error, "Failed to load phone preferences"));
|
||||
}
|
||||
return result.data || {};
|
||||
}, []);
|
||||
|
||||
const applyPreferences = useCallback((nextPreferences: OrganizationPreferences) => {
|
||||
const saved = nextPreferences.test_phone_number || "";
|
||||
setPreferences(nextPreferences);
|
||||
setPhoneNumber(saved);
|
||||
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
|
||||
setPhoneChanged(false);
|
||||
}, []);
|
||||
|
||||
// Check telephony configuration when dialog opens
|
||||
useEffect(() => {
|
||||
const checkConfig = async () => {
|
||||
|
|
@ -97,6 +123,33 @@ export const PhoneCallDialog = ({
|
|||
checkConfig();
|
||||
}, [open]);
|
||||
|
||||
// Load organization-scoped call preferences when dialog opens.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let cancelled = false;
|
||||
setPreferencesLoaded(false);
|
||||
|
||||
const loadPreferences = async () => {
|
||||
try {
|
||||
const nextPreferences = await fetchPreferences();
|
||||
if (cancelled) return;
|
||||
applyPreferences(nextPreferences);
|
||||
setPreferencesLoaded(true);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
applyPreferences({});
|
||||
setPreferencesLoaded(false);
|
||||
setCallError(err instanceof Error ? err.message : "Failed to load phone preferences");
|
||||
}
|
||||
};
|
||||
|
||||
loadPreferences();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [applyPreferences, fetchPreferences, open]);
|
||||
|
||||
// Reset state when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
|
|
@ -149,22 +202,9 @@ export const PhoneCallDialog = ({
|
|||
};
|
||||
}, [open, selectedConfigId]);
|
||||
|
||||
// Keep phoneNumber in sync with userConfig when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const saved = userConfig?.test_phone_number || "";
|
||||
setPhoneNumber(saved);
|
||||
setSipMode(/^(PJSIP|SIP)\//i.test(saved));
|
||||
setPhoneChanged(false);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
setCallLoading(false);
|
||||
}
|
||||
}, [open, userConfig?.test_phone_number]);
|
||||
|
||||
const handlePhoneInputChange = (formattedValue: string) => {
|
||||
setPhoneNumber(formattedValue);
|
||||
setPhoneChanged(formattedValue !== userConfig?.test_phone_number);
|
||||
setPhoneChanged(formattedValue !== (preferences.test_phone_number || ""));
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
};
|
||||
|
|
@ -174,17 +214,39 @@ export const PhoneCallDialog = ({
|
|||
router.push('/telephony-configurations');
|
||||
};
|
||||
|
||||
const savePhoneNumberPreference = async () => {
|
||||
const currentPreferences = preferencesLoaded ? preferences : await fetchPreferences();
|
||||
const result =
|
||||
await savePreferencesApiV1OrganizationsPreferencesPut({
|
||||
body: {
|
||||
...currentPreferences,
|
||||
test_phone_number: phoneNumber || null,
|
||||
},
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(detailFromError(result.error, "Failed to save phone preferences"));
|
||||
}
|
||||
if (!result.data) {
|
||||
throw new Error("Failed to save phone preferences");
|
||||
}
|
||||
|
||||
setPreferences(result.data);
|
||||
setPreferencesLoaded(true);
|
||||
setPhoneChanged(false);
|
||||
await refreshConfig();
|
||||
};
|
||||
|
||||
const handleStartCall = async () => {
|
||||
setCallLoading(true);
|
||||
setCallError(null);
|
||||
setCallSuccessMsg(null);
|
||||
try {
|
||||
if (!user || !userConfig) return;
|
||||
if (!user) return;
|
||||
|
||||
// Save phone number if it has changed
|
||||
if (phoneChanged) {
|
||||
await saveUserConfig({ ...userConfig, test_phone_number: phoneNumber });
|
||||
setPhoneChanged(false);
|
||||
await savePhoneNumberPreference();
|
||||
}
|
||||
|
||||
const response = await initiateCallApiV1TelephonyInitiateCallPost({
|
||||
|
|
|
|||
|
|
@ -7,8 +7,22 @@ import { useParams, useRouter } from "next/navigation";
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet, getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost, getWorkflowApiV1WorkflowFetchWorkflowIdGet } from "@/client/sdk.gen";
|
||||
import type { WorkflowResponse } from "@/client/types.gen";
|
||||
import {
|
||||
downloadWorkflowReportApiV1WorkflowWorkflowIdReportGet,
|
||||
getAmbientNoiseUploadUrlApiV1WorkflowAmbientNoiseUploadUrlPost,
|
||||
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get,
|
||||
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet,
|
||||
getWorkflowApiV1WorkflowFetchWorkflowIdGet,
|
||||
} from "@/client/sdk.gen";
|
||||
import type {
|
||||
OrganizationAiModelConfigurationResponse,
|
||||
OrganizationAiModelConfigurationV2,
|
||||
WorkflowResponse,
|
||||
} from "@/client/types.gen";
|
||||
import {
|
||||
AIModelConfigurationV2Editor,
|
||||
type ModelConfigurationDefaultsV2,
|
||||
} from "@/components/AIModelConfigurationV2Editor";
|
||||
import { FlowEdge, FlowNode } from "@/components/flow/types";
|
||||
import { LLMConfigSelector } from "@/components/LLMConfigSelector";
|
||||
import { ServiceConfigurationForm } from "@/components/ServiceConfigurationForm";
|
||||
|
|
@ -26,6 +40,7 @@ import { Textarea } from "@/components/ui/textarea";
|
|||
import { SETTINGS_DOCUMENTATION_URLS } from "@/constants/documentation";
|
||||
import { UnsavedChangesProvider, useUnsavedChanges, useUnsavedChangesContext } from "@/context/UnsavedChangesContext";
|
||||
import { useAudioPlayback } from "@/hooks/useAudioPlayback";
|
||||
import { detailFromError } from "@/lib/apiError";
|
||||
import { useAuth } from "@/lib/auth";
|
||||
import logger from "@/lib/logger";
|
||||
import {
|
||||
|
|
@ -1040,6 +1055,182 @@ function AgentUuidSection({ workflowUuid }: { workflowUuid: string }) {
|
|||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section: Model Overrides
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function withoutModelConfigurationOverrides(configurations: WorkflowConfigurations): WorkflowConfigurations {
|
||||
const next = { ...configurations };
|
||||
delete next.model_overrides;
|
||||
delete next.model_configuration_v2_override;
|
||||
return next;
|
||||
}
|
||||
|
||||
function WorkflowModelOverridesSection({
|
||||
workflowConfigurations,
|
||||
workflowName,
|
||||
onSave,
|
||||
modelConfigurationDefaults,
|
||||
organizationModelConfiguration,
|
||||
modelConfigurationLoading,
|
||||
modelConfigurationError,
|
||||
}: {
|
||||
workflowConfigurations: WorkflowConfigurations;
|
||||
workflowName: string;
|
||||
onSave: (configurations: WorkflowConfigurations, workflowName: string) => Promise<void>;
|
||||
modelConfigurationDefaults: ModelConfigurationDefaultsV2 | null;
|
||||
organizationModelConfiguration: OrganizationAiModelConfigurationResponse | null;
|
||||
modelConfigurationLoading: boolean;
|
||||
modelConfigurationError: string | null;
|
||||
}) {
|
||||
const savedV2Override = workflowConfigurations.model_configuration_v2_override;
|
||||
const hasSavedModelOverride = Boolean(savedV2Override || workflowConfigurations.model_overrides);
|
||||
const [overrideEnabled, setOverrideEnabled] = useState(Boolean(savedV2Override));
|
||||
const [isRemovingOverride, setIsRemovingOverride] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setOverrideEnabled(Boolean(workflowConfigurations.model_configuration_v2_override));
|
||||
}, [workflowConfigurations.model_configuration_v2_override]);
|
||||
|
||||
const source = organizationModelConfiguration?.source || "empty";
|
||||
const isV2 = source === "organization_v2";
|
||||
|
||||
const saveLegacyOverrides = async (config: Record<string, unknown>) => {
|
||||
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
|
||||
const modelOverrides = config.model_overrides as WorkflowConfigurations["model_overrides"] | undefined;
|
||||
if (modelOverrides) {
|
||||
nextConfigurations.model_overrides = modelOverrides;
|
||||
}
|
||||
await onSave(nextConfigurations, workflowName);
|
||||
};
|
||||
|
||||
const saveV2Override = async (configuration: OrganizationAiModelConfigurationV2) => {
|
||||
const nextConfigurations = withoutModelConfigurationOverrides(workflowConfigurations);
|
||||
nextConfigurations.model_configuration_v2_override = configuration;
|
||||
await onSave(nextConfigurations, workflowName);
|
||||
toast.success("Model override saved");
|
||||
};
|
||||
|
||||
const removeV2Override = async () => {
|
||||
setIsRemovingOverride(true);
|
||||
try {
|
||||
await onSave(withoutModelConfigurationOverrides(workflowConfigurations), workflowName);
|
||||
setOverrideEnabled(false);
|
||||
toast.success("Using organization model configuration");
|
||||
} finally {
|
||||
setIsRemovingOverride(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card id="models">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="h-4 w-4" />
|
||||
Model Overrides
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{isV2
|
||||
? "Override the full organization model configuration for this workflow."
|
||||
: "Override global model settings for this workflow. Toggle individual services to customize."}{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{modelConfigurationLoading && (
|
||||
<div className="flex items-center gap-2 rounded-md border p-4 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading model configuration
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modelConfigurationError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
{modelConfigurationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!modelConfigurationLoading && !modelConfigurationError && !isV2 && (
|
||||
<>
|
||||
{source === "legacy_user_v1" && (
|
||||
<div className="flex flex-col gap-3 rounded-md border bg-muted/30 p-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This workflow is using legacy model overrides. Migrate organization model configuration to use v2 overrides.
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" asChild>
|
||||
<Link href="/model-configurations?action=migrate_to_v2">Migrate to v2</Link>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<ServiceConfigurationForm
|
||||
mode="override"
|
||||
currentOverrides={workflowConfigurations.model_overrides}
|
||||
submitLabel="Save Model Overrides"
|
||||
onSave={saveLegacyOverrides}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!modelConfigurationLoading && !modelConfigurationError && isV2 && modelConfigurationDefaults && organizationModelConfiguration && (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-md border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="workflow-model-v2-override" className="text-sm font-medium">
|
||||
Override for this workflow
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{overrideEnabled
|
||||
? "This workflow uses its own complete model configuration."
|
||||
: "This workflow uses the organization model configuration."}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="workflow-model-v2-override"
|
||||
checked={overrideEnabled}
|
||||
onCheckedChange={setOverrideEnabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{overrideEnabled ? (
|
||||
<AIModelConfigurationV2Editor
|
||||
defaults={modelConfigurationDefaults}
|
||||
configuration={
|
||||
(savedV2Override as OrganizationAiModelConfigurationV2 | undefined)
|
||||
|| (organizationModelConfiguration.configuration as OrganizationAiModelConfigurationV2 | null)
|
||||
}
|
||||
effectiveConfiguration={
|
||||
savedV2Override
|
||||
? null
|
||||
: organizationModelConfiguration.effective_configuration
|
||||
}
|
||||
submitLabel="Save Model Override"
|
||||
onSave={saveV2Override}
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/20 p-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Using organization model configuration.
|
||||
</p>
|
||||
{hasSavedModelOverride && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3"
|
||||
onClick={removeV2Override}
|
||||
disabled={isRemovingOverride}
|
||||
>
|
||||
{isRemovingOverride ? "Saving..." : "Save Organization Configuration"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -1127,6 +1318,11 @@ function WorkflowSettingsInner({
|
|||
|
||||
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
|
||||
const [activeSection, setActiveSection] = useState("general");
|
||||
const [modelConfigurationDefaults, setModelConfigurationDefaults] = useState<ModelConfigurationDefaultsV2 | null>(null);
|
||||
const [organizationModelConfiguration, setOrganizationModelConfiguration] = useState<OrganizationAiModelConfigurationResponse | null>(null);
|
||||
const [modelConfigurationLoading, setModelConfigurationLoading] = useState(true);
|
||||
const [modelConfigurationError, setModelConfigurationError] = useState<string | null>(null);
|
||||
const hasFetchedModelConfiguration = useRef(false);
|
||||
|
||||
const workflowId = workflow.id;
|
||||
|
||||
|
|
@ -1166,6 +1362,37 @@ function WorkflowSettingsInner({
|
|||
user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFetchedModelConfiguration.current) return;
|
||||
hasFetchedModelConfiguration.current = true;
|
||||
|
||||
const loadModelConfiguration = async () => {
|
||||
setModelConfigurationLoading(true);
|
||||
setModelConfigurationError(null);
|
||||
const [defaultsResult, configurationResult] = await Promise.all([
|
||||
getModelConfigurationV2DefaultsApiV1OrganizationsModelConfigurationsV2DefaultsGet(),
|
||||
getModelConfigurationV2ApiV1OrganizationsModelConfigurationsV2Get(),
|
||||
]);
|
||||
|
||||
if (defaultsResult.error) {
|
||||
setModelConfigurationError(detailFromError(defaultsResult.error, "Failed to load model configuration defaults"));
|
||||
setModelConfigurationLoading(false);
|
||||
return;
|
||||
}
|
||||
if (configurationResult.error) {
|
||||
setModelConfigurationError(detailFromError(configurationResult.error, "Failed to load model configuration"));
|
||||
setModelConfigurationLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setModelConfigurationDefaults(defaultsResult.data as ModelConfigurationDefaultsV2);
|
||||
setOrganizationModelConfiguration(configurationResult.data || null);
|
||||
setModelConfigurationLoading(false);
|
||||
};
|
||||
|
||||
loadModelConfiguration();
|
||||
}, []);
|
||||
|
||||
// Intersection observer for active sidebar link
|
||||
useEffect(() => {
|
||||
const ids = NAV_ITEMS.map((n) => n.id);
|
||||
|
|
@ -1218,37 +1445,15 @@ function WorkflowSettingsInner({
|
|||
onSave={saveWorkflowConfigurations}
|
||||
/>
|
||||
|
||||
{/* Model Overrides */}
|
||||
<Card id="models">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Brain className="h-4 w-4" />
|
||||
Model Overrides
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Override global model settings for this workflow. Toggle individual services to
|
||||
customize.{" "}
|
||||
<a href={SETTINGS_DOCUMENTATION_URLS.modelOverrides} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-0.5 underline">Learn more <ExternalLink className="h-3 w-3" /></a>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ServiceConfigurationForm
|
||||
mode="override"
|
||||
currentOverrides={workflowConfigurations.model_overrides}
|
||||
submitLabel="Save Model Overrides"
|
||||
onSave={async (config) => {
|
||||
await saveWorkflowConfigurations(
|
||||
{
|
||||
...workflowConfigurations,
|
||||
model_overrides:
|
||||
config.model_overrides as WorkflowConfigurations["model_overrides"],
|
||||
} as WorkflowConfigurations,
|
||||
workflowName,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<WorkflowModelOverridesSection
|
||||
workflowConfigurations={workflowConfigurations}
|
||||
workflowName={workflowName}
|
||||
onSave={saveWorkflowConfigurations}
|
||||
modelConfigurationDefaults={modelConfigurationDefaults}
|
||||
organizationModelConfiguration={organizationModelConfiguration}
|
||||
modelConfigurationLoading={modelConfigurationLoading}
|
||||
modelConfigurationError={modelConfigurationError}
|
||||
/>
|
||||
|
||||
{/* Template Variables */}
|
||||
<TemplateVariablesSection
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load diff
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");
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import type { OrganizationAiModelConfigurationV2 } from "@/client/types.gen";
|
||||
|
||||
export interface AmbientNoiseConfiguration {
|
||||
enabled: boolean;
|
||||
volume: number;
|
||||
|
|
@ -64,6 +66,7 @@ export interface WorkflowConfigurations {
|
|||
voicemail_detection?: VoicemailDetectionConfiguration;
|
||||
context_compaction_enabled?: boolean; // Summarize context on node transitions to remove stale tool calls
|
||||
model_overrides?: ModelOverrides; // Per-workflow model configuration overrides
|
||||
model_configuration_v2_override?: OrganizationAiModelConfigurationV2; // Full v2 model configuration override
|
||||
[key: string]: unknown; // Allow additional properties for future configurations
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue