mirror of
https://github.com/dograh-hq/dograh.git
synced 2026-06-19 08:28:10 +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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue