mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-14 20:55:15 +02:00
feat(database-migrations): add migration to remove legacy model config tables and remove stale model connection code
This commit is contained in:
parent
50668775f8
commit
bd4a04f2e7
93 changed files with 956 additions and 11442 deletions
|
|
@ -1,6 +0,0 @@
|
|||
import { ImageModelManager } from "@/components/settings/image-model-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <ImageModelManager searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { LLMRoleManager } from "@/components/settings/llm-role-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <LLMRoleManager key={search_space_id} searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
import { VisionModelManager } from "@/components/settings/vision-model-manager";
|
||||
|
||||
export default async function Page({ params }: { params: Promise<{ search_space_id: string }> }) {
|
||||
const { search_space_id } = await params;
|
||||
return <VisionModelManager searchSpaceId={Number(search_space_id)} />;
|
||||
}
|
||||
|
|
@ -1,96 +0,0 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateImageGenConfigRequest,
|
||||
CreateImageGenConfigResponse,
|
||||
DeleteImageGenConfigResponse,
|
||||
GetImageGenConfigsResponse,
|
||||
UpdateImageGenConfigRequest,
|
||||
UpdateImageGenConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { imageGenConfigApiService } from "@/lib/apis/image-gen-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
/**
|
||||
* Mutation atom for creating a new ImageGenerationConfig
|
||||
*/
|
||||
export const createImageGenConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["image-gen-configs", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateImageGenConfigRequest) => {
|
||||
return imageGenConfigApiService.createConfig(request);
|
||||
},
|
||||
onSuccess: (_: CreateImageGenConfigResponse, request: CreateImageGenConfigRequest) => {
|
||||
toast.success(`${request.name} created`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create image model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation atom for updating an existing ImageGenerationConfig
|
||||
*/
|
||||
export const updateImageGenConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["image-gen-configs", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateImageGenConfigRequest) => {
|
||||
return imageGenConfigApiService.updateConfig(request);
|
||||
},
|
||||
onSuccess: (_: UpdateImageGenConfigResponse, request: UpdateImageGenConfigRequest) => {
|
||||
toast.success(`${request.data.name ?? "Configuration"} updated`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.imageGenConfigs.byId(request.id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update image model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation atom for deleting an ImageGenerationConfig
|
||||
*/
|
||||
export const deleteImageGenConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["image-gen-configs", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: { id: number; name: string }) => {
|
||||
return imageGenConfigApiService.deleteConfig(request.id);
|
||||
},
|
||||
onSuccess: (_: DeleteImageGenConfigResponse, request: { id: number; name: string }) => {
|
||||
toast.success(`${request.name} deleted`);
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
|
||||
(oldData: GetImageGenConfigsResponse | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((config) => config.id !== request.id);
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete image model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import { imageGenConfigApiService } from "@/lib/apis/image-gen-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
/**
|
||||
* Query atom for fetching user-created image gen configs for the active search space
|
||||
*/
|
||||
export const imageGenConfigsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.imageGenConfigs.all(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
return imageGenConfigApiService.getConfigs(Number(searchSpaceId));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Query atom for fetching global image gen configs (from YAML, negative IDs)
|
||||
*/
|
||||
export const globalImageGenConfigsAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.imageGenConfigs.global(),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
|
||||
queryFn: async () => {
|
||||
return imageGenConfigApiService.getGlobalConfigs();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateNewLLMConfigRequest,
|
||||
CreateNewLLMConfigResponse,
|
||||
DeleteNewLLMConfigRequest,
|
||||
DeleteNewLLMConfigResponse,
|
||||
GetNewLLMConfigsResponse,
|
||||
UpdateLLMPreferencesRequest,
|
||||
UpdateNewLLMConfigRequest,
|
||||
UpdateNewLLMConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
/**
|
||||
* Mutation atom for creating a new NewLLMConfig
|
||||
*/
|
||||
export const createNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["new-llm-configs", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateNewLLMConfigRequest) => {
|
||||
return newLLMConfigApiService.createConfig(request);
|
||||
},
|
||||
onSuccess: (_: CreateNewLLMConfigResponse, request: CreateNewLLMConfigRequest) => {
|
||||
toast.success(`${request.name} created`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation atom for updating an existing NewLLMConfig
|
||||
*/
|
||||
export const updateNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["new-llm-configs", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateNewLLMConfigRequest) => {
|
||||
return newLLMConfigApiService.updateConfig(request);
|
||||
},
|
||||
onSuccess: (_: UpdateNewLLMConfigResponse, request: UpdateNewLLMConfigRequest) => {
|
||||
toast.success(`${request.data.name ?? "Configuration"} updated`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.newLLMConfigs.byId(request.id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation atom for deleting a NewLLMConfig
|
||||
*/
|
||||
export const deleteNewLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["new-llm-configs", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: DeleteNewLLMConfigRequest & { name: string }) => {
|
||||
return newLLMConfigApiService.deleteConfig({ id: request.id });
|
||||
},
|
||||
onSuccess: (
|
||||
_: DeleteNewLLMConfigResponse,
|
||||
request: DeleteNewLLMConfigRequest & { name: string }
|
||||
) => {
|
||||
toast.success(`${request.name} deleted`);
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
|
||||
(oldData: GetNewLLMConfigsResponse | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((config) => config.id !== request.id);
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Mutation atom for updating LLM preferences (role assignments)
|
||||
*/
|
||||
export const updateLLMPreferencesMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["llm-preferences", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateLLMPreferencesRequest) => {
|
||||
return newLLMConfigApiService.updateLLMPreferences(request);
|
||||
},
|
||||
onSuccess: (_data, request: UpdateLLMPreferencesRequest) => {
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
|
||||
(old: Record<string, unknown> | undefined) => ({ ...old, ...request.data })
|
||||
);
|
||||
// Automation eligibility is derived from these model preferences
|
||||
// (agent/image/vision). Invalidate it so the automations gate alert
|
||||
// reflects the new selection without a manual refresh.
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.automations.modelEligibility(Number(searchSpaceId)),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update LLM preferences");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,98 +0,0 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import type { LLMModel } from "@/contracts/enums/llm-models";
|
||||
import { LLM_MODELS } from "@/contracts/enums/llm-models";
|
||||
import { newLLMConfigApiService } from "@/lib/apis/new-llm-config-api.service";
|
||||
import { getBearerToken } from "@/lib/auth-utils";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
/**
|
||||
* Query atom for fetching all NewLLMConfigs for the active search space
|
||||
*/
|
||||
export const newLLMConfigsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.newLLMConfigs.all(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
return newLLMConfigApiService.getConfigs({
|
||||
search_space_id: Number(searchSpaceId),
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Query atom for fetching global NewLLMConfigs (from YAML, negative IDs)
|
||||
*/
|
||||
export const globalNewLLMConfigsAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.newLLMConfigs.global(),
|
||||
staleTime: 10 * 60 * 1000, // 10 minutes - global configs rarely change
|
||||
enabled: !!getBearerToken(),
|
||||
queryFn: async () => {
|
||||
return newLLMConfigApiService.getGlobalConfigs();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Query atom for fetching LLM preferences (role assignments) for the active search space
|
||||
*/
|
||||
export const llmPreferencesAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.newLLMConfigs.preferences(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
queryFn: async () => {
|
||||
return newLLMConfigApiService.getLLMPreferences(Number(searchSpaceId));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Query atom for fetching default system instructions template
|
||||
*/
|
||||
export const defaultSystemInstructionsAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.newLLMConfigs.defaultInstructions(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hour - this rarely changes
|
||||
queryFn: async () => {
|
||||
return newLLMConfigApiService.getDefaultSystemInstructions();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* Query atom for the dynamic model catalogue.
|
||||
* Fetched from the backend (which proxies OpenRouter's public API).
|
||||
* Falls back to the static hardcoded list on error.
|
||||
*/
|
||||
export const modelListAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.newLLMConfigs.modelList(),
|
||||
staleTime: 60 * 60 * 1000, // 1 hour - models don't change often
|
||||
placeholderData: LLM_MODELS,
|
||||
queryFn: async (): Promise<LLMModel[]> => {
|
||||
const data = await newLLMConfigApiService.getModels();
|
||||
const dynamicModels = data.map((m) => ({
|
||||
value: m.value,
|
||||
label: m.label,
|
||||
provider: m.provider,
|
||||
contextWindow: m.context_window ?? undefined,
|
||||
}));
|
||||
|
||||
// Providers covered by the dynamic API (from OpenRouter mapping).
|
||||
// For uncovered providers (Ollama, Groq, Bedrock, etc.) keep the
|
||||
// hand-curated static suggestions so users still see model options.
|
||||
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
|
||||
const staticFallbacks = LLM_MODELS.filter((m) => !coveredProviders.has(m.provider));
|
||||
|
||||
return [...dynamicModels, ...staticFallbacks];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
import { atomWithMutation } from "jotai-tanstack-query";
|
||||
import { toast } from "sonner";
|
||||
import type {
|
||||
CreateVisionLLMConfigRequest,
|
||||
CreateVisionLLMConfigResponse,
|
||||
DeleteVisionLLMConfigResponse,
|
||||
GetVisionLLMConfigsResponse,
|
||||
UpdateVisionLLMConfigRequest,
|
||||
UpdateVisionLLMConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { queryClient } from "@/lib/query-client/client";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const createVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "create"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: CreateVisionLLMConfigRequest) => {
|
||||
return visionLLMConfigApiService.createConfig(request);
|
||||
},
|
||||
onSuccess: (_: CreateVisionLLMConfigResponse, request: CreateVisionLLMConfigRequest) => {
|
||||
toast.success(`${request.name} created`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to create vision model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const updateVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "update"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: UpdateVisionLLMConfigRequest) => {
|
||||
return visionLLMConfigApiService.updateConfig(request);
|
||||
},
|
||||
onSuccess: (_: UpdateVisionLLMConfigResponse, request: UpdateVisionLLMConfigRequest) => {
|
||||
toast.success(`${request.data.name ?? "Configuration"} updated`);
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
});
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: cacheKeys.visionLLMConfigs.byId(request.id),
|
||||
});
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to update vision model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const deleteVisionLLMConfigMutationAtom = atomWithMutation((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
mutationKey: ["vision-llm-configs", "delete"],
|
||||
meta: { suppressGlobalErrorToast: true },
|
||||
enabled: !!searchSpaceId,
|
||||
mutationFn: async (request: { id: number; name: string }) => {
|
||||
return visionLLMConfigApiService.deleteConfig(request.id);
|
||||
},
|
||||
onSuccess: (_: DeleteVisionLLMConfigResponse, request: { id: number; name: string }) => {
|
||||
toast.success(`${request.name} deleted`);
|
||||
queryClient.setQueryData(
|
||||
cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
(oldData: GetVisionLLMConfigsResponse | undefined) => {
|
||||
if (!oldData) return oldData;
|
||||
return oldData.filter((config) => config.id !== request.id);
|
||||
}
|
||||
);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message || "Failed to delete vision model");
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
import { atomWithQuery } from "jotai-tanstack-query";
|
||||
import type { LLMModel } from "@/contracts/enums/llm-models";
|
||||
import { VISION_MODELS } from "@/contracts/enums/vision-providers";
|
||||
import { visionLLMConfigApiService } from "@/lib/apis/vision-llm-config-api.service";
|
||||
import { cacheKeys } from "@/lib/query-client/cache-keys";
|
||||
import { activeSearchSpaceIdAtom } from "../search-spaces/search-space-query.atoms";
|
||||
|
||||
export const visionLLMConfigsAtom = atomWithQuery((get) => {
|
||||
const searchSpaceId = get(activeSearchSpaceIdAtom);
|
||||
|
||||
return {
|
||||
queryKey: cacheKeys.visionLLMConfigs.all(Number(searchSpaceId)),
|
||||
enabled: !!searchSpaceId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return visionLLMConfigApiService.getConfigs(Number(searchSpaceId));
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const globalVisionLLMConfigsAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.visionLLMConfigs.global(),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
queryFn: async () => {
|
||||
return visionLLMConfigApiService.getGlobalConfigs();
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const visionModelListAtom = atomWithQuery(() => {
|
||||
return {
|
||||
queryKey: cacheKeys.visionLLMConfigs.modelList(),
|
||||
staleTime: 60 * 60 * 1000,
|
||||
placeholderData: VISION_MODELS,
|
||||
queryFn: async (): Promise<LLMModel[]> => {
|
||||
const data = await visionLLMConfigApiService.getModels();
|
||||
const dynamicModels = data.map((m) => ({
|
||||
value: m.value,
|
||||
label: m.label,
|
||||
provider: m.provider,
|
||||
contextWindow: m.context_window ?? undefined,
|
||||
}));
|
||||
|
||||
const coveredProviders = new Set(dynamicModels.map((m) => m.provider));
|
||||
const staticFallbacks = VISION_MODELS.filter((m) => !coveredProviders.has(m.provider));
|
||||
|
||||
return [...dynamicModels, ...staticFallbacks];
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
"use client";
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
||||
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
GlobalNewLLMConfig,
|
||||
GlobalVisionLLMConfig,
|
||||
ImageGenerationConfig,
|
||||
NewLLMConfigPublic,
|
||||
VisionLLMConfig,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ModelSelector } from "./model-selector";
|
||||
|
||||
interface ChatHeaderProps {
|
||||
|
|
@ -20,148 +8,9 @@ interface ChatHeaderProps {
|
|||
}
|
||||
|
||||
export function ChatHeader({ searchSpaceId, className }: ChatHeaderProps) {
|
||||
// LLM config dialog state
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [selectedConfig, setSelectedConfig] = useState<
|
||||
NewLLMConfigPublic | GlobalNewLLMConfig | null
|
||||
>(null);
|
||||
const [isGlobal, setIsGlobal] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// Image config dialog state
|
||||
const [imageDialogOpen, setImageDialogOpen] = useState(false);
|
||||
const [selectedImageConfig, setSelectedImageConfig] = useState<
|
||||
ImageGenerationConfig | GlobalImageGenConfig | null
|
||||
>(null);
|
||||
const [isImageGlobal, setIsImageGlobal] = useState(false);
|
||||
const [imageDialogMode, setImageDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// Vision config dialog state
|
||||
const [visionDialogOpen, setVisionDialogOpen] = useState(false);
|
||||
const [selectedVisionConfig, setSelectedVisionConfig] = useState<
|
||||
VisionLLMConfig | GlobalVisionLLMConfig | null
|
||||
>(null);
|
||||
const [isVisionGlobal, setIsVisionGlobal] = useState(false);
|
||||
const [visionDialogMode, setVisionDialogMode] = useState<"create" | "edit" | "view">("view");
|
||||
|
||||
// Default provider for create dialogs
|
||||
const [defaultLLMProvider, setDefaultLLMProvider] = useState<string | undefined>();
|
||||
const [defaultImageProvider, setDefaultImageProvider] = useState<string | undefined>();
|
||||
const [defaultVisionProvider, setDefaultVisionProvider] = useState<string | undefined>();
|
||||
|
||||
// LLM handlers
|
||||
const handleEditLLMConfig = useCallback(
|
||||
(config: NewLLMConfigPublic | GlobalNewLLMConfig, global: boolean) => {
|
||||
setSelectedConfig(config);
|
||||
setIsGlobal(global);
|
||||
setDialogMode(global ? "view" : "edit");
|
||||
setDefaultLLMProvider(undefined);
|
||||
setDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleAddNewLLM = useCallback((provider?: string) => {
|
||||
setSelectedConfig(null);
|
||||
setIsGlobal(false);
|
||||
setDialogMode("create");
|
||||
setDefaultLLMProvider(provider);
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDialogClose = useCallback((open: boolean) => {
|
||||
setDialogOpen(open);
|
||||
if (!open) setSelectedConfig(null);
|
||||
}, []);
|
||||
|
||||
// Image model handlers
|
||||
const handleAddImageModel = useCallback((provider?: string) => {
|
||||
setSelectedImageConfig(null);
|
||||
setIsImageGlobal(false);
|
||||
setImageDialogMode("create");
|
||||
setDefaultImageProvider(provider);
|
||||
setImageDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditImageConfig = useCallback(
|
||||
(config: ImageGenerationConfig | GlobalImageGenConfig, global: boolean) => {
|
||||
setSelectedImageConfig(config);
|
||||
setIsImageGlobal(global);
|
||||
setImageDialogMode(global ? "view" : "edit");
|
||||
setDefaultImageProvider(undefined);
|
||||
setImageDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleImageDialogClose = useCallback((open: boolean) => {
|
||||
setImageDialogOpen(open);
|
||||
if (!open) setSelectedImageConfig(null);
|
||||
}, []);
|
||||
|
||||
// Vision model handlers
|
||||
const handleAddVisionModel = useCallback((provider?: string) => {
|
||||
setSelectedVisionConfig(null);
|
||||
setIsVisionGlobal(false);
|
||||
setVisionDialogMode("create");
|
||||
setDefaultVisionProvider(provider);
|
||||
setVisionDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleEditVisionConfig = useCallback(
|
||||
(config: VisionLLMConfig | GlobalVisionLLMConfig, global: boolean) => {
|
||||
setSelectedVisionConfig(config);
|
||||
setIsVisionGlobal(global);
|
||||
setVisionDialogMode(global ? "view" : "edit");
|
||||
setDefaultVisionProvider(undefined);
|
||||
setVisionDialogOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleVisionDialogClose = useCallback((open: boolean) => {
|
||||
setVisionDialogOpen(open);
|
||||
if (!open) setSelectedVisionConfig(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<ModelSelector
|
||||
onEditLLM={handleEditLLMConfig}
|
||||
onAddNewLLM={handleAddNewLLM}
|
||||
onEditImage={handleEditImageConfig}
|
||||
onAddNewImage={handleAddImageModel}
|
||||
onEditVision={handleEditVisionConfig}
|
||||
onAddNewVision={handleAddVisionModel}
|
||||
className={className}
|
||||
/>
|
||||
<ModelConfigDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={handleDialogClose}
|
||||
config={selectedConfig}
|
||||
isGlobal={isGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={dialogMode}
|
||||
defaultProvider={defaultLLMProvider}
|
||||
/>
|
||||
<ImageConfigDialog
|
||||
open={imageDialogOpen}
|
||||
onOpenChange={handleImageDialogClose}
|
||||
config={selectedImageConfig}
|
||||
isGlobal={isImageGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={imageDialogMode}
|
||||
defaultProvider={defaultImageProvider}
|
||||
/>
|
||||
<VisionConfigDialog
|
||||
open={visionDialogOpen}
|
||||
onOpenChange={handleVisionDialogClose}
|
||||
config={selectedVisionConfig}
|
||||
isGlobal={isVisionGlobal}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={visionDialogMode}
|
||||
defaultProvider={defaultVisionProvider}
|
||||
/>
|
||||
<ModelSelector searchSpaceId={searchSpaceId} className={className} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,423 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, FileText, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deleteNewLLMConfigMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { ModelConfigDialog } from "@/components/shared/model-config-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { NewLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface AgentModelManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function AgentModelManager({ searchSpaceId }: AgentModelManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
// Mutations
|
||||
const { mutateAsync: deleteConfig, isPending: isDeleting } = useAtomValue(
|
||||
deleteNewLLMConfigMutationAtom
|
||||
);
|
||||
|
||||
// Queries
|
||||
const {
|
||||
data: configs,
|
||||
isFetching: isLoading,
|
||||
error: fetchError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(newLLMConfigsAtom);
|
||||
const { data: globalConfigs = [] } = useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
// Members for user resolution
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||
if (members) {
|
||||
for (const m of members) {
|
||||
map.set(m.user_id, {
|
||||
name: m.user_display_name || m.user_email || "Unknown",
|
||||
email: m.user_email || undefined,
|
||||
avatarUrl: m.user_avatar_url || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [members]);
|
||||
|
||||
// Permissions
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate =
|
||||
!!access && (access.is_owner || (access.permissions?.includes("llm_configs:create") ?? false));
|
||||
const canUpdate =
|
||||
!!access && (access.is_owner || (access.permissions?.includes("llm_configs:update") ?? false));
|
||||
const canDelete =
|
||||
!!access && (access.is_owner || (access.permissions?.includes("llm_configs:delete") ?? false));
|
||||
const isReadOnly = !canCreate && !canUpdate && !canDelete;
|
||||
|
||||
// Local state
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<NewLLMConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<NewLLMConfig | null>(null);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation state
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (config: NewLLMConfig) => {
|
||||
setEditingConfig(config);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openNewDialog = () => {
|
||||
setEditingConfig(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openNewDialog}
|
||||
className="gap-2 border-transparent bg-white text-[#1f1f1f] font-medium hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-transparent dark:bg-white dark:text-[#1f1f1f]"
|
||||
>
|
||||
Add Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fetch Error Alert */}
|
||||
{fetchError && (
|
||||
<div>
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{fetchError?.message ?? "Failed to load configurations"}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Read-only / Limited permissions notice */}
|
||||
{access && !isLoading && isReadOnly && (
|
||||
<div>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You have <span className="font-medium">read-only</span> access to LLM
|
||||
configurations. Contact a space owner to request additional permissions.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{access && !isLoading && !isReadOnly && (!canCreate || !canUpdate || !canDelete) && (
|
||||
<div>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You can{" "}
|
||||
{[canCreate && "create", canUpdate && "edit", canDelete && "delete"]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}{" "}
|
||||
configurations
|
||||
{!canDelete && ", but cannot delete them"}.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global Configs Info */}
|
||||
{(isLoading || globalConfigs.length > 0) && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[1.625em] items-center">
|
||||
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{globalConfigs.length} global {globalConfigs.length === 1 ? "model" : "models"}
|
||||
</span>{" "}
|
||||
available from your administrator.
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Configurations List */}
|
||||
{!isLoading && (
|
||||
<div className="space-y-4">
|
||||
{configs?.length === 0 ? (
|
||||
<div>
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">No Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your first model to power chat, reports, and other agent capabilities"
|
||||
: "No models have been added to this space yet. Contact a space owner to add one"}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{configs?.map((config) => {
|
||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||
|
||||
return (
|
||||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
{/* Header: Icon + Name + Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDelete && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feature badges */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
{config.citations_enabled && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
Citations
|
||||
</Badge>
|
||||
)}
|
||||
{!config.use_default_system_instructions &&
|
||||
config.system_instructions && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0.5 border-0 text-muted-foreground bg-muted"
|
||||
>
|
||||
<FileText className="h-2.5 w-2.5 mr-1" />
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="mt-auto space-y-2">
|
||||
<Separator className="bg-accent" />
|
||||
<div className="flex items-center">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{member.email || member.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Configuration Dialog */}
|
||||
<ModelConfigDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingConfig(null);
|
||||
}}
|
||||
config={editingConfig}
|
||||
isGlobal={false}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>? This
|
||||
action cannot be undone.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isDeleting ? (
|
||||
<>
|
||||
<Spinner size="sm" className="mr-2" />
|
||||
Deleting
|
||||
</>
|
||||
) : (
|
||||
"Delete"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,489 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { deleteImageGenConfigMutationAtom } from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { ImageConfigDialog } from "@/components/shared/image-config-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { ImageGenerationConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageModelManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function ImageModelManager({ searchSpaceId }: ImageModelManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const {
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useAtomValue(deleteImageGenConfigMutationAtom);
|
||||
|
||||
const {
|
||||
data: userConfigs,
|
||||
isFetching: configsLoading,
|
||||
error: fetchError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(imageGenConfigsAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalLoading } =
|
||||
useAtomValue(globalImageGenConfigsAtom);
|
||||
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||
if (members) {
|
||||
for (const m of members) {
|
||||
map.set(m.user_id, {
|
||||
name: m.user_display_name || m.user_email || "Unknown",
|
||||
email: m.user_email || undefined,
|
||||
avatarUrl: m.user_avatar_url || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [members]);
|
||||
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate =
|
||||
!!access &&
|
||||
(access.is_owner || (access.permissions?.includes("image_generations:create") ?? false));
|
||||
const canDelete =
|
||||
!!access &&
|
||||
(access.is_owner || (access.permissions?.includes("image_generations:delete") ?? false));
|
||||
const canUpdate = canCreate;
|
||||
const isReadOnly = !canCreate && !canDelete;
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<ImageGenerationConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<ImageGenerationConfig | null>(null);
|
||||
|
||||
const isLoading = configsLoading || globalLoading;
|
||||
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
const openEditDialog = (config: ImageGenerationConfig) => {
|
||||
setEditingConfig(config);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openNewDialog = () => {
|
||||
setEditingConfig(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openNewDialog}
|
||||
className="gap-2 border-transparent bg-white text-[#1f1f1f] font-medium hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-transparent dark:bg-white dark:text-[#1f1f1f]"
|
||||
>
|
||||
Add Image Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Errors */}
|
||||
{errors.map((err) => (
|
||||
<div key={err?.message}>
|
||||
<Alert variant="destructive" className="py-3">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Read-only / Limited permissions notice */}
|
||||
{access && !isLoading && isReadOnly && (
|
||||
<div>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You have <span className="font-medium">read-only</span> access to image generation
|
||||
configurations. Contact a space owner to request additional permissions.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||
<div>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You can{" "}
|
||||
{[canCreate && "create and edit", canDelete && "delete"]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}{" "}
|
||||
image model configurations
|
||||
{!canDelete && ", but cannot delete them"}.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Global info */}
|
||||
{(isLoading ||
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0) && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[1.625em] items-center">
|
||||
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
|
||||
global image{" "}
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length ===
|
||||
1
|
||||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator. {(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
const premium = nonAuto.filter(
|
||||
(g) =>
|
||||
"billing_tier" in g &&
|
||||
(g as { billing_tier?: string }).billing_tier === "premium"
|
||||
).length;
|
||||
const free = nonAuto.length - premium;
|
||||
if (premium > 0 && free > 0) {
|
||||
return `${premium} premium, ${free} free.`;
|
||||
}
|
||||
if (premium > 0) {
|
||||
return `All ${premium} premium — debits your shared credit pool.`;
|
||||
}
|
||||
return `All ${free} free.`;
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global Image Models — read-only cards with per-model Free/Premium
|
||||
badges. Mirrors the badge palette used by the chat role selector
|
||||
(`llm-role-manager.tsx`) so the meaning is consistent across
|
||||
every model-configuration surface (chat / image / vision). */}
|
||||
{!isLoading &&
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{globalConfigs
|
||||
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
|
||||
.map((cfg) => {
|
||||
const billingTier =
|
||||
("billing_tier" in cfg &&
|
||||
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
|
||||
(cfg as { billing_tier?: string }).billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<Card
|
||||
key={cfg.id}
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(cfg.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{cfg.name}
|
||||
</h4>
|
||||
{isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
|
||||
{cfg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto space-y-2">
|
||||
<Separator className="bg-accent" />
|
||||
<div className="flex items-center">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{cfg.model_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Configs */}
|
||||
{!isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{(userConfigs?.length ?? 0) === 0 ? (
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">No Image Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your own image generation model (DALL-E 3, GPT Image 1, etc.)"
|
||||
: "No image models have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{userConfigs?.map((config) => {
|
||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||
|
||||
return (
|
||||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
{/* Header: Icon + Name + Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDelete && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="mt-auto space-y-2">
|
||||
<Separator className="bg-accent" />
|
||||
<div className="flex items-center">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{member.email || member.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Dialog — shared component */}
|
||||
<ImageConfigDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingConfig(null);
|
||||
}}
|
||||
config={editingConfig}
|
||||
isGlobal={false}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Image Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
|
||||
{isDeleting && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,443 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
AlertCircle,
|
||||
Bot,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
FileText,
|
||||
ImageIcon,
|
||||
RefreshCw,
|
||||
ScanEye,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
globalImageGenConfigsAtom,
|
||||
imageGenConfigsAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-query.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalNewLLMConfigsAtom,
|
||||
llmPreferencesAtom,
|
||||
newLLMConfigsAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import {
|
||||
globalVisionLLMConfigsAtom,
|
||||
visionLLMConfigsAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ROLE_DESCRIPTIONS = {
|
||||
agent: {
|
||||
icon: Bot,
|
||||
title: "Chat model",
|
||||
description: "Primary model for chat interactions and agent operations",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "agent_llm_id" as const,
|
||||
configType: "llm" as const,
|
||||
},
|
||||
image_generation: {
|
||||
icon: ImageIcon,
|
||||
title: "Image Generation Model",
|
||||
description: "Model used for AI image generation (DALL-E, GPT Image, etc.)",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "image_generation_config_id" as const,
|
||||
configType: "image" as const,
|
||||
},
|
||||
vision: {
|
||||
icon: ScanEye,
|
||||
title: "Vision LLM",
|
||||
description: "Vision-capable model for screenshot analysis and context extraction",
|
||||
color: "text-muted-foreground",
|
||||
bgColor: "bg-muted",
|
||||
prefKey: "vision_llm_config_id" as const,
|
||||
configType: "vision" as const,
|
||||
},
|
||||
};
|
||||
|
||||
interface LLMRoleManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
export function LLMRoleManager({ searchSpaceId }: LLMRoleManagerProps) {
|
||||
// LLM configs
|
||||
const {
|
||||
data: newLLMConfigs = [],
|
||||
isFetching: configsLoading,
|
||||
error: configsError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(newLLMConfigsAtom);
|
||||
const {
|
||||
data: globalConfigs = [],
|
||||
isFetching: globalConfigsLoading,
|
||||
error: globalConfigsError,
|
||||
} = useAtomValue(globalNewLLMConfigsAtom);
|
||||
|
||||
// Image gen configs
|
||||
const {
|
||||
data: userImageConfigs = [],
|
||||
isFetching: imageConfigsLoading,
|
||||
error: imageConfigsError,
|
||||
} = useAtomValue(imageGenConfigsAtom);
|
||||
const {
|
||||
data: globalImageConfigs = [],
|
||||
isFetching: globalImageConfigsLoading,
|
||||
error: globalImageConfigsError,
|
||||
} = useAtomValue(globalImageGenConfigsAtom);
|
||||
|
||||
// Vision LLM configs
|
||||
const {
|
||||
data: userVisionConfigs = [],
|
||||
isFetching: visionConfigsLoading,
|
||||
error: visionConfigsError,
|
||||
} = useAtomValue(visionLLMConfigsAtom);
|
||||
const {
|
||||
data: globalVisionConfigs = [],
|
||||
isFetching: globalVisionConfigsLoading,
|
||||
error: globalVisionConfigsError,
|
||||
} = useAtomValue(globalVisionLLMConfigsAtom);
|
||||
|
||||
// Preferences
|
||||
const {
|
||||
data: preferences = {},
|
||||
isFetching: preferencesLoading,
|
||||
error: preferencesError,
|
||||
} = useAtomValue(llmPreferencesAtom);
|
||||
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const [assignments, setAssignments] = useState<Record<string, number | null>>(() => ({
|
||||
agent_llm_id: preferences.agent_llm_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
|
||||
}));
|
||||
|
||||
// Sync local state when preferences load/change. Without this, the selects
|
||||
// stay on their initial (often empty) value while the query is in flight,
|
||||
// so a saved assignment — including Auto mode (id 0) — never appears.
|
||||
useEffect(() => {
|
||||
setAssignments({
|
||||
agent_llm_id: preferences.agent_llm_id ?? null,
|
||||
image_generation_config_id: preferences.image_generation_config_id ?? null,
|
||||
vision_llm_config_id: preferences.vision_llm_config_id ?? null,
|
||||
});
|
||||
}, [
|
||||
preferences.agent_llm_id,
|
||||
preferences.image_generation_config_id,
|
||||
preferences.vision_llm_config_id,
|
||||
]);
|
||||
|
||||
const [savingRole, setSavingRole] = useState<string | null>(null);
|
||||
|
||||
const handleRoleAssignment = useCallback(
|
||||
async (prefKey: string, configId: string) => {
|
||||
// "unassigned" clears the role (null). Every other option — including
|
||||
// Auto mode, whose config id is 0 — must be sent as-is. Using a falsy
|
||||
// check here (e.g. `value || undefined`) would drop id 0 and silently
|
||||
// fail to persist Auto mode.
|
||||
const value = configId === "unassigned" ? null : Number(configId);
|
||||
|
||||
setAssignments((prev) => ({ ...prev, [prefKey]: value }));
|
||||
setSavingRole(prefKey);
|
||||
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { [prefKey]: value },
|
||||
});
|
||||
toast.success("Role assignment updated");
|
||||
} finally {
|
||||
setSavingRole(null);
|
||||
}
|
||||
},
|
||||
[updatePreferences, searchSpaceId]
|
||||
);
|
||||
|
||||
// Combine global and custom LLM configs
|
||||
const allLLMConfigs = [
|
||||
...globalConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...newLLMConfigs.filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
// Combine global and custom image gen configs
|
||||
const allImageConfigs = [
|
||||
...globalImageConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...(userImageConfigs ?? []).filter((config) => config.id && config.id.toString().trim() !== ""),
|
||||
];
|
||||
|
||||
// Combine global and custom vision LLM configs
|
||||
const allVisionConfigs = [
|
||||
...globalVisionConfigs.map((config) => ({ ...config, is_global: true })),
|
||||
...(userVisionConfigs ?? []).filter(
|
||||
(config) => config.id && config.id.toString().trim() !== ""
|
||||
),
|
||||
];
|
||||
|
||||
const isLoading =
|
||||
configsLoading ||
|
||||
preferencesLoading ||
|
||||
globalConfigsLoading ||
|
||||
imageConfigsLoading ||
|
||||
globalImageConfigsLoading ||
|
||||
visionConfigsLoading ||
|
||||
globalVisionConfigsLoading;
|
||||
const hasError =
|
||||
configsError ||
|
||||
preferencesError ||
|
||||
globalConfigsError ||
|
||||
imageConfigsError ||
|
||||
globalImageConfigsError ||
|
||||
visionConfigsError ||
|
||||
globalVisionConfigsError;
|
||||
const hasAnyConfigs = allLLMConfigs.length > 0 || allImageConfigs.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-5 md:space-y-6">
|
||||
{/* Header actions */}
|
||||
<div className="flex items-center justify-start">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", isLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{hasError && (
|
||||
<div>
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
{(configsError?.message ?? "Failed to load LLM configurations") ||
|
||||
(preferencesError?.message ?? "Failed to load preferences") ||
|
||||
(globalConfigsError?.message ?? "Failed to load global configurations")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading Skeleton */}
|
||||
{isLoading && (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No configs warning */}
|
||||
{!isLoading && !hasError && !hasAnyConfigs && (
|
||||
<Alert variant="destructive" className="py-3 md:py-4">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">
|
||||
No configurations found. Please add at least one LLM provider or image model in the
|
||||
respective settings tabs before assigning roles.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Role Assignment Cards */}
|
||||
{!isLoading && !hasError && hasAnyConfigs && (
|
||||
<div className="grid gap-4 grid-cols-1 lg:grid-cols-2">
|
||||
{Object.entries(ROLE_DESCRIPTIONS).map(([key, role]) => {
|
||||
const IconComponent = role.icon;
|
||||
const currentAssignment = assignments[role.prefKey as keyof typeof assignments];
|
||||
|
||||
// Pick the right config lists based on role type
|
||||
const roleGlobalConfigs =
|
||||
role.configType === "image"
|
||||
? globalImageConfigs
|
||||
: role.configType === "vision"
|
||||
? globalVisionConfigs
|
||||
: globalConfigs;
|
||||
const roleUserConfigs =
|
||||
role.configType === "image"
|
||||
? (userImageConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||
: role.configType === "vision"
|
||||
? (userVisionConfigs ?? []).filter((c) => c.id && c.id.toString().trim() !== "")
|
||||
: newLLMConfigs.filter((c) => c.id && c.id.toString().trim() !== "");
|
||||
const roleAllConfigs =
|
||||
role.configType === "image"
|
||||
? allImageConfigs
|
||||
: role.configType === "vision"
|
||||
? allVisionConfigs
|
||||
: allLLMConfigs;
|
||||
|
||||
const assignedConfig = roleAllConfigs.find((config) => config.id === currentAssignment);
|
||||
const isAssigned = !!assignedConfig;
|
||||
|
||||
return (
|
||||
<div key={key}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 md:p-5 space-y-4">
|
||||
{/* Role Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-center w-9 h-9 rounded-lg shrink-0",
|
||||
role.bgColor
|
||||
)}
|
||||
>
|
||||
<IconComponent className={cn("w-4 h-4", role.color)} />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<h4 className="text-sm font-semibold tracking-tight">{role.title}</h4>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5">
|
||||
{role.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{savingRole === role.prefKey ? (
|
||||
<Spinner size="sm" className="shrink-0 mt-0.5 text-muted-foreground" />
|
||||
) : isAssigned ? (
|
||||
<CircleCheck className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<CircleDashed className="w-4 h-4 text-muted-foreground/40 shrink-0 mt-0.5" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selector */}
|
||||
<div className="space-y-1.5">
|
||||
<Label className="text-xs font-medium text-muted-foreground">
|
||||
Configuration
|
||||
</Label>
|
||||
<Select
|
||||
value={assignedConfig ? assignedConfig.id.toString() : "unassigned"}
|
||||
onValueChange={(value) => handleRoleAssignment(role.prefKey, value)}
|
||||
>
|
||||
<SelectTrigger className="w-full h-9 md:h-10 text-xs md:text-sm">
|
||||
<SelectValue placeholder="Select a configuration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-w-[calc(100vw-2rem)] select-none">
|
||||
<SelectItem
|
||||
value="unassigned"
|
||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||
>
|
||||
<span className="text-muted-foreground">Unassigned</span>
|
||||
</SelectItem>
|
||||
|
||||
{/* Global Configurations */}
|
||||
{roleGlobalConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[11px] md:text-xs font-semibold text-muted-foreground px-2 py-1 md:py-1.5">
|
||||
Global Configurations
|
||||
</SelectLabel>
|
||||
{roleGlobalConfigs.map((config) => {
|
||||
const isAuto = "is_auto_mode" in config && config.is_auto_mode;
|
||||
// Read billing_tier from the global config; default to "free"
|
||||
// for legacy YAMLs / Auto stub. Premium gets a purple badge,
|
||||
// free gets an emerald one — same palette as the chat
|
||||
// model selector so the meaning is consistent across
|
||||
// surfaces (issues E, H).
|
||||
const billingTier =
|
||||
("billing_tier" in config &&
|
||||
typeof config.billing_tier === "string" &&
|
||||
config.billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||
textValue={config.name}
|
||||
>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
||||
<span className="truncate text-xs md:text-sm">
|
||||
{config.name}
|
||||
</span>
|
||||
{isAuto ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-zinc-200 text-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Recommended
|
||||
</Badge>
|
||||
) : isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0 [[data-slot=select-trigger]_&]:hidden"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Custom Configurations */}
|
||||
{roleUserConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-[11px] md:text-xs font-semibold text-muted-foreground px-2 py-1 md:py-1.5">
|
||||
Your Configurations
|
||||
</SelectLabel>
|
||||
{roleUserConfigs.map((config) => (
|
||||
<SelectItem
|
||||
key={config.id}
|
||||
value={config.id.toString()}
|
||||
className="text-xs md:text-sm py-1.5 md:py-2"
|
||||
>
|
||||
<div className="flex items-center gap-1 md:gap-1.5 flex-wrap min-w-0">
|
||||
<span className="truncate text-xs md:text-sm">
|
||||
{config.name}
|
||||
</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Dot, Info, Pencil, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms";
|
||||
import { deleteVisionLLMConfigMutationAtom } from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||
import {
|
||||
globalVisionLLMConfigsAtom,
|
||||
visionLLMConfigsAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||
import { VisionConfigDialog } from "@/components/shared/vision-config-dialog";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import type { VisionLLMConfig } from "@/contracts/types/new-llm-config.types";
|
||||
import { useMediaQuery } from "@/hooks/use-media-query";
|
||||
import { getProviderIcon } from "@/lib/provider-icons";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VisionModelManagerProps {
|
||||
searchSpaceId: number;
|
||||
}
|
||||
|
||||
function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
export function VisionModelManager({ searchSpaceId }: VisionModelManagerProps) {
|
||||
const isDesktop = useMediaQuery("(min-width: 768px)");
|
||||
|
||||
const {
|
||||
mutateAsync: deleteConfig,
|
||||
isPending: isDeleting,
|
||||
error: deleteError,
|
||||
} = useAtomValue(deleteVisionLLMConfigMutationAtom);
|
||||
|
||||
const {
|
||||
data: userConfigs,
|
||||
isFetching: configsLoading,
|
||||
error: fetchError,
|
||||
refetch: refreshConfigs,
|
||||
} = useAtomValue(visionLLMConfigsAtom);
|
||||
const { data: globalConfigs = [], isFetching: globalLoading } = useAtomValue(
|
||||
globalVisionLLMConfigsAtom
|
||||
);
|
||||
|
||||
const { data: members } = useAtomValue(membersAtom);
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, { name: string; email?: string; avatarUrl?: string }>();
|
||||
if (members) {
|
||||
for (const m of members) {
|
||||
map.set(m.user_id, {
|
||||
name: m.user_display_name || m.user_email || "Unknown",
|
||||
email: m.user_email || undefined,
|
||||
avatarUrl: m.user_avatar_url || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [members]);
|
||||
|
||||
const { data: access } = useAtomValue(myAccessAtom);
|
||||
const canCreate = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("vision_configs:create") ?? false;
|
||||
}, [access]);
|
||||
const canDelete = useMemo(() => {
|
||||
if (!access) return false;
|
||||
if (access.is_owner) return true;
|
||||
return access.permissions?.includes("vision_configs:delete") ?? false;
|
||||
}, [access]);
|
||||
const canUpdate = canCreate;
|
||||
const isReadOnly = !canCreate && !canDelete;
|
||||
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [editingConfig, setEditingConfig] = useState<VisionLLMConfig | null>(null);
|
||||
const [configToDelete, setConfigToDelete] = useState<VisionLLMConfig | null>(null);
|
||||
|
||||
const isLoading = configsLoading || globalLoading;
|
||||
const errors = [deleteError, fetchError].filter(Boolean) as Error[];
|
||||
|
||||
const openEditDialog = (config: VisionLLMConfig) => {
|
||||
setEditingConfig(config);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const openNewDialog = () => {
|
||||
setEditingConfig(null);
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!configToDelete) return;
|
||||
try {
|
||||
await deleteConfig({ id: configToDelete.id, name: configToDelete.name });
|
||||
setConfigToDelete(null);
|
||||
} catch {
|
||||
// Error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => refreshConfigs()}
|
||||
disabled={isLoading}
|
||||
className="gap-2"
|
||||
>
|
||||
<RefreshCw className={cn("h-3.5 w-3.5", configsLoading && "animate-spin")} />
|
||||
Refresh
|
||||
</Button>
|
||||
{canCreate && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={openNewDialog}
|
||||
className="gap-2 border-transparent bg-white text-[#1f1f1f] font-medium hover:bg-zinc-100 hover:text-[#1f1f1f] dark:border-transparent dark:bg-white dark:text-[#1f1f1f]"
|
||||
>
|
||||
Add Vision Model
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{errors.map((err) => (
|
||||
<div key={err?.message}>
|
||||
<Alert variant="destructive" className="py-3">
|
||||
<AlertCircle className="h-3 w-3 md:h-4 md:w-4 shrink-0" />
|
||||
<AlertDescription className="text-xs md:text-sm">{err?.message}</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{access && !isLoading && isReadOnly && (
|
||||
<div>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You have <span className="font-medium">read-only</span> access to vision model
|
||||
configurations. Contact a space owner to request additional permissions.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{access && !isLoading && !isReadOnly && (!canCreate || !canDelete) && (
|
||||
<div>
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
<p>
|
||||
You can{" "}
|
||||
{[canCreate && "create and edit", canDelete && "delete"]
|
||||
.filter(Boolean)
|
||||
.join(" and ")}{" "}
|
||||
vision model configurations
|
||||
{!canDelete && ", but cannot delete them"}.
|
||||
</p>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(isLoading ||
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0) && (
|
||||
<Alert>
|
||||
<Info />
|
||||
<AlertDescription>
|
||||
{isLoading ? (
|
||||
<div className="flex min-h-[1.625em] items-center">
|
||||
<Skeleton className="h-4 w-60 bg-accent-foreground/15" />
|
||||
</div>
|
||||
) : (
|
||||
<p>
|
||||
<span className="font-medium">
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length}{" "}
|
||||
global vision{" "}
|
||||
{globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length ===
|
||||
1
|
||||
? "model"
|
||||
: "models"}
|
||||
</span>{" "}
|
||||
available from your administrator. {(() => {
|
||||
const nonAuto = globalConfigs.filter(
|
||||
(g) => !("is_auto_mode" in g && g.is_auto_mode)
|
||||
);
|
||||
const premium = nonAuto.filter(
|
||||
(g) =>
|
||||
"billing_tier" in g &&
|
||||
(g as { billing_tier?: string }).billing_tier === "premium"
|
||||
).length;
|
||||
const free = nonAuto.length - premium;
|
||||
if (premium > 0 && free > 0) {
|
||||
return `${premium} premium, ${free} free.`;
|
||||
}
|
||||
if (premium > 0) {
|
||||
return `All ${premium} premium — debits your shared credit pool.`;
|
||||
}
|
||||
return `All ${free} free.`;
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* Global Vision Models — read-only cards with per-model Free/Premium
|
||||
badges. Mirrors the badge palette used by the chat role selector
|
||||
(`llm-role-manager.tsx`) so the meaning is consistent across
|
||||
every model-configuration surface (chat / image / vision). */}
|
||||
{!isLoading &&
|
||||
globalConfigs.filter((g) => !("is_auto_mode" in g && g.is_auto_mode)).length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{globalConfigs
|
||||
.filter((g) => !("is_auto_mode" in g && g.is_auto_mode))
|
||||
.map((cfg) => {
|
||||
const billingTier =
|
||||
("billing_tier" in cfg &&
|
||||
typeof (cfg as { billing_tier?: string }).billing_tier === "string" &&
|
||||
(cfg as { billing_tier?: string }).billing_tier) ||
|
||||
"free";
|
||||
const isPremium = billingTier === "premium";
|
||||
return (
|
||||
<Card
|
||||
key={cfg.id}
|
||||
className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full"
|
||||
>
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(cfg.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-1.5">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{cfg.name}
|
||||
</h4>
|
||||
{isPremium ? (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border-0"
|
||||
>
|
||||
Premium
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[8px] md:text-[9px] shrink-0 bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border-0"
|
||||
>
|
||||
Free
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{cfg.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 line-clamp-2">
|
||||
{cfg.description}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-auto space-y-2">
|
||||
<Separator className="bg-accent" />
|
||||
<div className="flex items-center">
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{cfg.model_name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{["skeleton-a", "skeleton-b", "skeleton-c"].map((key) => (
|
||||
<Card key={key} className="border-accent bg-accent/20">
|
||||
<CardContent className="p-4 flex flex-col gap-3 min-h-32">
|
||||
<Skeleton className="h-4 w-32 md:w-40 bg-accent" />
|
||||
<Skeleton className="h-3 w-full bg-accent" />
|
||||
<Skeleton className="h-3 w-24 md:w-28 bg-accent mt-auto" />
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && (
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
{(userConfigs?.length ?? 0) === 0 ? (
|
||||
<Card className="border-0 bg-transparent shadow-none">
|
||||
<CardContent className="flex flex-col items-center justify-center py-10 md:py-16 text-center">
|
||||
<h3 className="text-sm md:text-base font-semibold mb-2">No Vision Models Yet</h3>
|
||||
<p className="text-[11px] md:text-xs text-muted-foreground max-w-sm mb-4">
|
||||
{canCreate
|
||||
? "Add your own vision-capable model (GPT-4o, Claude, Gemini, etc.)"
|
||||
: "No vision models have been added to this space yet. Contact a space owner to add one."}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-3 grid-cols-1 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{userConfigs?.map((config) => {
|
||||
const member = config.user_id ? memberMap.get(config.user_id) : null;
|
||||
|
||||
return (
|
||||
<div key={config.id}>
|
||||
<Card className="group relative overflow-hidden transition-all duration-200 border-accent bg-accent/20 hover:shadow-md h-full">
|
||||
<CardContent className="p-4 flex flex-col gap-3 h-full">
|
||||
{/* Header: Icon + Name + Actions */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2.5 min-w-0 flex-1">
|
||||
<div className="shrink-0">
|
||||
{getProviderIcon(config.provider, { className: "size-4" })}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h4 className="text-sm font-semibold tracking-tight truncate">
|
||||
{config.name}
|
||||
</h4>
|
||||
{config.description && (
|
||||
<p className="text-[11px] text-muted-foreground/70 truncate mt-0.5">
|
||||
{config.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{(canUpdate || canDelete) && (
|
||||
<div className="flex items-center gap-1 shrink-0 sm:w-0 sm:overflow-hidden sm:group-hover:w-auto sm:opacity-0 sm:group-hover:opacity-100 transition-all duration-150">
|
||||
{canUpdate && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(config)}
|
||||
className="h-6 w-6 text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Edit</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{canDelete && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setConfigToDelete(config)}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Delete</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Date + Creator */}
|
||||
<div className="mt-auto space-y-2">
|
||||
<Separator className="bg-accent" />
|
||||
<div className="flex items-center">
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground/60 whitespace-nowrap">
|
||||
{new Date(config.created_at).toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
})}
|
||||
</span>
|
||||
{member && (
|
||||
<>
|
||||
<Dot className="h-4 w-4 text-muted-foreground/30 shrink-0" />
|
||||
<TooltipProvider>
|
||||
<Tooltip open={isDesktop ? undefined : false}>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="min-w-0 flex items-center gap-1.5 cursor-default">
|
||||
<Avatar className="size-4.5 shrink-0">
|
||||
{member.avatarUrl && (
|
||||
<AvatarImage src={member.avatarUrl} alt={member.name} />
|
||||
)}
|
||||
<AvatarFallback className="text-[9px]">
|
||||
{getInitials(member.name)}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-[11px] text-muted-foreground/60 truncate">
|
||||
{member.name}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{member.email || member.name}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<VisionConfigDialog
|
||||
open={isDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setIsDialogOpen(open);
|
||||
if (!open) setEditingConfig(null);
|
||||
}}
|
||||
config={editingConfig}
|
||||
isGlobal={false}
|
||||
searchSpaceId={searchSpaceId}
|
||||
mode={editingConfig ? "edit" : "create"}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={!!configToDelete}
|
||||
onOpenChange={(open) => !open && setConfigToDelete(null)}
|
||||
>
|
||||
<AlertDialogContent className="select-none">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete Vision Model</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Are you sure you want to delete{" "}
|
||||
<span className="font-semibold text-foreground">{configToDelete?.name}</span>?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="relative bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
<span className={isDeleting ? "opacity-0" : ""}>Delete</span>
|
||||
{isDeleting && <Spinner size="sm" className="absolute" />}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,456 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createImageGenConfigMutationAtom,
|
||||
updateImageGenConfigMutationAtom,
|
||||
} from "@/atoms/image-gen-config/image-gen-config-mutation.atoms";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { IMAGE_GEN_MODELS, IMAGE_GEN_PROVIDERS } from "@/contracts/enums/image-gen-providers";
|
||||
import type {
|
||||
GlobalImageGenConfig,
|
||||
ImageGenerationConfig,
|
||||
ImageGenProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ImageConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: ImageGenerationConfig | GlobalImageGenConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
defaultProvider?: string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
};
|
||||
|
||||
export function ImageConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
defaultProvider,
|
||||
}: ImageConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
setFormData({
|
||||
name: config.name || "",
|
||||
description: config.description || "",
|
||||
provider: config.provider || "",
|
||||
model_name: config.model_name || "",
|
||||
api_key: (config as ImageGenerationConfig).api_key || "",
|
||||
api_base: config.api_base || "",
|
||||
api_version: config.api_version || "",
|
||||
});
|
||||
} else if (mode === "create") {
|
||||
setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal, defaultProvider]);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateImageGenConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const suggestedModels = useMemo(() => {
|
||||
if (!formData.provider) return [];
|
||||
return IMAGE_GEN_MODELS.filter((m) => m.provider === formData.provider);
|
||||
}, [formData.provider]);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add Image Model";
|
||||
if (isGlobal) return "View Global Image Model";
|
||||
return "Edit Image Model";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new image generation provider";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your image model settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: result.id },
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as ImageGenProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save image config:", error);
|
||||
toast.error("Failed to save image model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
formData,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { image_generation_config_id: config.id },
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set image model:", error);
|
||||
toast.error("Failed to set image model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
|
||||
const selectedProvider = IMAGE_GEN_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isGlobal && config && (
|
||||
<>
|
||||
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize, create a new model.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., My DALL-E 3"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-popover-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IMAGE_GEN_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
{suggestedModels.length > 0 ? (
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
className="w-full justify-between font-normal bg-transparent"
|
||||
>
|
||||
{formData.model_name || "Select or type a model..."}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder="Search or type model..."
|
||||
value={formData.model_name}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Type a custom model name
|
||||
</span>
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{suggestedModels.map((m) => (
|
||||
<CommandItem
|
||||
key={m.value}
|
||||
value={m.value}
|
||||
onSelect={() => {
|
||||
setFormData((p) => ({ ...p, model_name: m.value }));
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === m.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<span className="font-mono text-sm">{m.value}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{m.label}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<Input
|
||||
placeholder="e.g., dall-e-3"
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, model_name: e.target.value }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="relative text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{mode === "edit" ? "Save Changes" : "Add Model"}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="relative text-sm h-9"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,527 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { Check, ChevronDown, ChevronsUpDown } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { type Resolver, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
defaultSystemInstructionsAtom,
|
||||
modelListAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-query.atoms";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { LLM_PROVIDERS } from "@/contracts/enums/llm-providers";
|
||||
import type { CreateNewLLMConfigRequest } from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
import InferenceParamsEditor from "../inference-params-editor";
|
||||
|
||||
// Form schema with zod
|
||||
const formSchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(100),
|
||||
description: z.string().max(500).optional().nullable(),
|
||||
provider: z.string().min(1, "Provider is required"),
|
||||
custom_provider: z.string().max(100).optional().nullable(),
|
||||
model_name: z.string().min(1, "Model name is required").max(100),
|
||||
api_key: z.string().min(1, "API key is required"),
|
||||
api_base: z.string().max(500).optional().nullable(),
|
||||
litellm_params: z.record(z.string(), z.any()).optional().nullable(),
|
||||
system_instructions: z.string().default(""),
|
||||
use_default_system_instructions: z.boolean().default(true),
|
||||
citations_enabled: z.boolean().default(true),
|
||||
search_space_id: z.number(),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
export type LLMConfigFormData = CreateNewLLMConfigRequest;
|
||||
|
||||
interface LLMConfigFormProps {
|
||||
initialData?: Partial<LLMConfigFormData>;
|
||||
searchSpaceId: number;
|
||||
onSubmit: (data: LLMConfigFormData) => Promise<void>;
|
||||
mode?: "create" | "edit";
|
||||
showAdvanced?: boolean;
|
||||
formId?: string;
|
||||
}
|
||||
|
||||
export function LLMConfigForm({
|
||||
initialData,
|
||||
searchSpaceId,
|
||||
onSubmit,
|
||||
mode = "create",
|
||||
showAdvanced = true,
|
||||
formId,
|
||||
}: LLMConfigFormProps) {
|
||||
const { data: defaultInstructions, isSuccess: defaultInstructionsLoaded } = useAtomValue(
|
||||
defaultSystemInstructionsAtom
|
||||
);
|
||||
const { data: dynamicModels } = useAtomValue(modelListAtom);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [systemInstructionsOpen, setSystemInstructionsOpen] = useState(false);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema) as Resolver<FormValues>,
|
||||
defaultValues: {
|
||||
name: initialData?.name ?? "",
|
||||
description: initialData?.description ?? "",
|
||||
provider: initialData?.provider ?? "",
|
||||
custom_provider: initialData?.custom_provider ?? "",
|
||||
model_name: initialData?.model_name ?? "",
|
||||
api_key: initialData?.api_key ?? "",
|
||||
api_base: initialData?.api_base ?? "",
|
||||
litellm_params: initialData?.litellm_params ?? {},
|
||||
system_instructions: initialData?.system_instructions ?? "",
|
||||
use_default_system_instructions: initialData?.use_default_system_instructions ?? true,
|
||||
citations_enabled: initialData?.citations_enabled ?? true,
|
||||
search_space_id: searchSpaceId,
|
||||
},
|
||||
});
|
||||
|
||||
// Load default instructions when available (only for new configs)
|
||||
useEffect(() => {
|
||||
if (
|
||||
mode === "create" &&
|
||||
defaultInstructionsLoaded &&
|
||||
defaultInstructions?.default_system_instructions &&
|
||||
!form.getValues("system_instructions")
|
||||
) {
|
||||
form.setValue("system_instructions", defaultInstructions.default_system_instructions);
|
||||
}
|
||||
}, [defaultInstructionsLoaded, defaultInstructions, mode, form]);
|
||||
|
||||
const watchProvider = form.watch("provider");
|
||||
const selectedProvider = LLM_PROVIDERS.find((p) => p.value === watchProvider);
|
||||
const availableModels = useMemo(
|
||||
() => (dynamicModels ?? []).filter((m) => m.provider === watchProvider),
|
||||
[dynamicModels, watchProvider]
|
||||
);
|
||||
|
||||
const handleProviderChange = (value: string) => {
|
||||
form.setValue("provider", value);
|
||||
form.setValue("model_name", "");
|
||||
|
||||
// Auto-fill API base for certain providers
|
||||
const provider = LLM_PROVIDERS.find((p) => p.value === value);
|
||||
if (provider?.apiBase) {
|
||||
form.setValue("api_base", provider.apiBase);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: FormValues) => {
|
||||
await onSubmit(values as LLMConfigFormData);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form id={formId} onSubmit={form.handleSubmit(handleFormSubmit)} className="space-y-6">
|
||||
{/* Model Configuration Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="text-xs sm:text-sm font-medium text-muted-foreground">
|
||||
Model Configuration
|
||||
</div>
|
||||
|
||||
{/* Name & Description */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Configuration Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="e.g., My GPT-4 Agent" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-muted-foreground text-xs sm:text-sm">
|
||||
Description
|
||||
<Badge variant="outline" className="ml-2 text-[10px]">
|
||||
Optional
|
||||
</Badge>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Brief description" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Provider Selection */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">LLM Provider</FormLabel>
|
||||
<Select value={field.value} onValueChange={handleProviderChange}>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{LLM_PROVIDERS.map((provider) => (
|
||||
<SelectItem
|
||||
key={provider.value}
|
||||
value={provider.value}
|
||||
description={provider.description}
|
||||
>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Provider (conditional) */}
|
||||
{watchProvider === "CUSTOM" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="custom_provider"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">Custom Provider Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="my-custom-provider" {...field} value={field.value ?? ""} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Model Name with Combobox */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="model_name"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-col">
|
||||
<FormLabel className="text-xs sm:text-sm">Model Name</FormLabel>
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<FormControl>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modelComboboxOpen}
|
||||
className={cn(
|
||||
"w-full justify-between border-popover-border bg-transparent font-normal",
|
||||
!field.value && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{field.value || "Select a model"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</FormControl>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder={selectedProvider?.example || "Search model name"}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
/>
|
||||
<CommandList className="max-h-[300px]">
|
||||
<CommandEmpty>
|
||||
<div className="py-3 text-center text-sm text-muted-foreground">
|
||||
{field.value ? `Using: "${field.value}"` : "Type your model name"}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{availableModels.length > 0 && (
|
||||
<CommandGroup heading="Suggested Models">
|
||||
{availableModels
|
||||
.filter(
|
||||
(model) =>
|
||||
!field.value ||
|
||||
model.value.toLowerCase().includes(field.value.toLowerCase()) ||
|
||||
model.label.toLowerCase().includes(field.value.toLowerCase())
|
||||
)
|
||||
.slice(0, 50)
|
||||
.map((model) => (
|
||||
<CommandItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
onSelect={(value) => {
|
||||
field.onChange(value);
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
className="py-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
field.value === model.value ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{model.label}</div>
|
||||
{model.contextWindow && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Context: {model.contextWindow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{selectedProvider?.example && (
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Example: {selectedProvider.example}
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* API Credentials */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_key"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="text-xs sm:text-sm">API Key</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={watchProvider === "OLLAMA" ? "Any value" : "sk-..."}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
{watchProvider === "OLLAMA" && (
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Ollama doesn't require auth — enter any value
|
||||
</FormDescription>
|
||||
)}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="api_base"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel className="flex items-center gap-2 text-xs sm:text-sm">
|
||||
API Base URL
|
||||
{selectedProvider?.apiBase && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Auto-filled
|
||||
</Badge>
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "https://api.example.com/v1"}
|
||||
{...field}
|
||||
value={field.value ?? ""}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ollama Quick Actions */}
|
||||
{watchProvider === "OLLAMA" && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => form.setValue("api_base", "http://localhost:11434")}
|
||||
>
|
||||
localhost:11434
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => form.setValue("api_base", "http://host.docker.internal:11434")}
|
||||
>
|
||||
Docker
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Parameters */}
|
||||
{showAdvanced && (
|
||||
<>
|
||||
<Separator className="bg-popover-border" />
|
||||
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-between px-0 py-2 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
|
||||
>
|
||||
<span>Advanced Parameters</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
advancedOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="litellm_params"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<InferenceParamsEditor
|
||||
params={field.value || {}}
|
||||
setParams={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* System Instructions & Citations Section */}
|
||||
<Separator className="bg-popover-border" />
|
||||
<Collapsible open={systemInstructionsOpen} onOpenChange={setSystemInstructionsOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="h-auto w-full justify-between px-0 py-2 text-xs font-medium text-muted-foreground hover:bg-transparent hover:text-accent-foreground sm:text-sm"
|
||||
>
|
||||
<span>System Instructions</span>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
systemInstructionsOpen && "rotate-180"
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{/* System Instructions */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="system_instructions"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-xs sm:text-sm">Instructions for the AI</FormLabel>
|
||||
{defaultInstructions && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
field.onChange(defaultInstructions.default_system_instructions)
|
||||
}
|
||||
className="h-7 text-[10px] sm:text-xs text-muted-foreground hover:text-accent-foreground"
|
||||
>
|
||||
Reset to Default
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder="Enter system instructions for the AI..."
|
||||
rows={6}
|
||||
className="font-mono text-[11px] sm:text-xs resize-none"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Use {"{resolved_today}"} to include today's date dynamically
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Citations Toggle */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="citations_enabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex items-center justify-between rounded-lg border p-3 bg-muted/30">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-xs sm:text-sm font-medium">
|
||||
Enable Citations
|
||||
</FormLabel>
|
||||
<FormDescription className="text-[10px] sm:text-xs">
|
||||
Include [citation:id] references to source documents
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch checked={field.value} onCheckedChange={field.onChange} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,339 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createNewLLMConfigMutationAtom,
|
||||
updateLLMPreferencesMutationAtom,
|
||||
updateNewLLMConfigMutationAtom,
|
||||
} from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import { LLMConfigForm, type LLMConfigFormData } from "@/components/shared/llm-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import type {
|
||||
GlobalNewLLMConfig,
|
||||
LiteLLMProvider,
|
||||
NewLLMConfigPublic,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
|
||||
interface ModelConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: NewLLMConfigPublic | GlobalNewLLMConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
defaultProvider?: string;
|
||||
}
|
||||
|
||||
export function ModelConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
defaultProvider,
|
||||
}: ModelConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateNewLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add New Configuration";
|
||||
if (isGlobal) return "View Global Configuration";
|
||||
return "Edit Configuration";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new LLM provider for this search space";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your configuration settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (data: LLMConfigFormData) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
...data,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: result.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
provider: data.provider,
|
||||
custom_provider: data.custom_provider,
|
||||
model_name: data.model_name,
|
||||
api_key: data.api_key,
|
||||
api_base: data.api_base,
|
||||
litellm_params: data.litellm_params,
|
||||
system_instructions: data.system_instructions,
|
||||
use_default_system_instructions: data.use_default_system_instructions,
|
||||
citations_enabled: data.citations_enabled,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save configuration:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]
|
||||
);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: {
|
||||
agent_llm_id: config.id,
|
||||
},
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set model:", error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
{!isGlobal && mode !== "create" && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
Custom
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize settings, create a new
|
||||
configuration based on this template.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{mode === "create" ? (
|
||||
<LLMConfigForm
|
||||
key={defaultProvider ?? "no-provider"}
|
||||
searchSpaceId={searchSpaceId}
|
||||
onSubmit={handleSubmit}
|
||||
mode="create"
|
||||
formId="model-config-form"
|
||||
initialData={
|
||||
defaultProvider ? { provider: defaultProvider as LiteLLMProvider } : undefined
|
||||
}
|
||||
/>
|
||||
) : isGlobal && config ? (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Configuration Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/50" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border/50" />
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Citations
|
||||
</div>
|
||||
<Badge
|
||||
variant={config.citations_enabled ? "default" : "secondary"}
|
||||
className="w-fit"
|
||||
>
|
||||
{config.citations_enabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.system_instructions && (
|
||||
<>
|
||||
<div className="h-px bg-border/50" />
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
System Instructions
|
||||
</div>
|
||||
<div className="p-3 rounded-lg bg-muted/50 border border-border/50">
|
||||
<p className="text-xs font-mono text-muted-foreground whitespace-pre-wrap line-clamp-10">
|
||||
{config.system_instructions}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : config ? (
|
||||
<LLMConfigForm
|
||||
searchSpaceId={searchSpaceId}
|
||||
initialData={{
|
||||
name: config.name,
|
||||
description: config.description,
|
||||
provider: config.provider as LiteLLMProvider,
|
||||
custom_provider: config.custom_provider,
|
||||
model_name: config.model_name,
|
||||
api_key: "api_key" in config ? (config.api_key as string) : "",
|
||||
api_base: config.api_base,
|
||||
litellm_params: config.litellm_params,
|
||||
system_instructions: config.system_instructions,
|
||||
use_default_system_instructions: config.use_default_system_instructions,
|
||||
citations_enabled: config.citations_enabled,
|
||||
search_space_id: searchSpaceId,
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
mode="edit"
|
||||
formId="model-config-form"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Fixed footer */}
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (!isGlobal && config) ? (
|
||||
<Button
|
||||
type="submit"
|
||||
form="model-config-form"
|
||||
disabled={isSubmitting}
|
||||
className="relative text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{mode === "edit" ? "Save Changes" : "Add Model"}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="relative text-sm h-9"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,478 +0,0 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import { AlertCircle, Check, ChevronsUpDown } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { updateLLMPreferencesMutationAtom } from "@/atoms/new-llm-config/new-llm-config-mutation.atoms";
|
||||
import {
|
||||
createVisionLLMConfigMutationAtom,
|
||||
updateVisionLLMConfigMutationAtom,
|
||||
} from "@/atoms/vision-llm-config/vision-llm-config-mutation.atoms";
|
||||
import { visionModelListAtom } from "@/atoms/vision-llm-config/vision-llm-config-query.atoms";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { VISION_PROVIDERS } from "@/contracts/enums/vision-providers";
|
||||
import type {
|
||||
GlobalVisionLLMConfig,
|
||||
VisionLLMConfig,
|
||||
VisionProvider,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface VisionConfigDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
config: VisionLLMConfig | GlobalVisionLLMConfig | null;
|
||||
isGlobal: boolean;
|
||||
searchSpaceId: number;
|
||||
mode: "create" | "edit" | "view";
|
||||
defaultProvider?: string;
|
||||
}
|
||||
|
||||
const INITIAL_FORM = {
|
||||
name: "",
|
||||
description: "",
|
||||
provider: "",
|
||||
model_name: "",
|
||||
api_key: "",
|
||||
api_base: "",
|
||||
api_version: "",
|
||||
};
|
||||
|
||||
export function VisionConfigDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
config,
|
||||
isGlobal,
|
||||
searchSpaceId,
|
||||
mode,
|
||||
defaultProvider,
|
||||
}: VisionConfigDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState(INITIAL_FORM);
|
||||
const [scrollPos, setScrollPos] = useState<"top" | "middle" | "bottom">("top");
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (mode === "edit" && config && !isGlobal) {
|
||||
setFormData({
|
||||
name: config.name || "",
|
||||
description: config.description || "",
|
||||
provider: config.provider || "",
|
||||
model_name: config.model_name || "",
|
||||
api_key: (config as VisionLLMConfig).api_key || "",
|
||||
api_base: config.api_base || "",
|
||||
api_version: (config as VisionLLMConfig).api_version || "",
|
||||
});
|
||||
} else if (mode === "create") {
|
||||
setFormData({ ...INITIAL_FORM, provider: defaultProvider ?? "" });
|
||||
}
|
||||
setScrollPos("top");
|
||||
}
|
||||
}, [open, mode, config, isGlobal, defaultProvider]);
|
||||
|
||||
const { mutateAsync: createConfig } = useAtomValue(createVisionLLMConfigMutationAtom);
|
||||
const { mutateAsync: updateConfig } = useAtomValue(updateVisionLLMConfigMutationAtom);
|
||||
const { mutateAsync: updatePreferences } = useAtomValue(updateLLMPreferencesMutationAtom);
|
||||
|
||||
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const el = e.currentTarget;
|
||||
const atTop = el.scrollTop <= 2;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight <= 2;
|
||||
setScrollPos(atTop ? "top" : atBottom ? "bottom" : "middle");
|
||||
}, []);
|
||||
|
||||
const getTitle = () => {
|
||||
if (mode === "create") return "Add Vision Model";
|
||||
if (isGlobal) return "View Global Vision Model";
|
||||
return "Edit Vision Model";
|
||||
};
|
||||
|
||||
const getSubtitle = () => {
|
||||
if (mode === "create") return "Set up a new vision-capable LLM provider";
|
||||
if (isGlobal) return "Read-only global configuration";
|
||||
return "Update your vision model settings";
|
||||
};
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
if (mode === "create") {
|
||||
const result = await createConfig({
|
||||
name: formData.name,
|
||||
provider: formData.provider as VisionProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
description: formData.description || undefined,
|
||||
search_space_id: searchSpaceId,
|
||||
});
|
||||
if (result?.id) {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { vision_llm_config_id: result.id },
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} else if (!isGlobal && config) {
|
||||
await updateConfig({
|
||||
id: config.id,
|
||||
data: {
|
||||
name: formData.name,
|
||||
description: formData.description || undefined,
|
||||
provider: formData.provider as VisionProvider,
|
||||
model_name: formData.model_name,
|
||||
api_key: formData.api_key,
|
||||
api_base: formData.api_base || undefined,
|
||||
api_version: formData.api_version || undefined,
|
||||
},
|
||||
});
|
||||
onOpenChange(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save vision config:", error);
|
||||
toast.error("Failed to save vision model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [
|
||||
mode,
|
||||
isGlobal,
|
||||
config,
|
||||
formData,
|
||||
searchSpaceId,
|
||||
createConfig,
|
||||
updateConfig,
|
||||
updatePreferences,
|
||||
onOpenChange,
|
||||
]);
|
||||
|
||||
const handleUseGlobalConfig = useCallback(async () => {
|
||||
if (!config || !isGlobal) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await updatePreferences({
|
||||
search_space_id: searchSpaceId,
|
||||
data: { vision_llm_config_id: config.id },
|
||||
});
|
||||
toast.success(`Now using ${config.name}`);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to set vision model:", error);
|
||||
toast.error("Failed to set vision model");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [config, isGlobal, searchSpaceId, updatePreferences, onOpenChange]);
|
||||
|
||||
const { data: dynamicModels } = useAtomValue(visionModelListAtom);
|
||||
const [modelComboboxOpen, setModelComboboxOpen] = useState(false);
|
||||
|
||||
const availableModels = useMemo(
|
||||
() => (dynamicModels ?? []).filter((m) => m.provider === formData.provider),
|
||||
[dynamicModels, formData.provider]
|
||||
);
|
||||
|
||||
const isFormValid = formData.name && formData.provider && formData.model_name && formData.api_key;
|
||||
const selectedProvider = VISION_PROVIDERS.find((p) => p.value === formData.provider);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-lg h-[85vh] flex flex-col p-0 gap-0 overflow-hidden"
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogTitle className="sr-only">{getTitle()}</DialogTitle>
|
||||
|
||||
<div className="flex items-start justify-between px-6 pt-6 pb-4 pr-14">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold tracking-tight">{getTitle()}</h2>
|
||||
{isGlobal && mode !== "create" && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
Global
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{getSubtitle()}</p>
|
||||
{config && mode !== "create" && (
|
||||
<p className="text-xs font-mono text-muted-foreground/70">{config.model_name}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-6 py-5"
|
||||
style={{
|
||||
maskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
WebkitMaskImage: `linear-gradient(to bottom, ${scrollPos === "top" ? "black" : "transparent"}, black 16px, black calc(100% - 16px), ${scrollPos === "bottom" ? "black" : "transparent"})`,
|
||||
}}
|
||||
>
|
||||
{isGlobal && config && (
|
||||
<>
|
||||
<Alert className="mb-5 border-amber-500/30 bg-amber-500/5">
|
||||
<AlertCircle className="size-4 text-amber-500" />
|
||||
<AlertDescription className="text-sm text-amber-700 dark:text-amber-400">
|
||||
Global configurations are read-only. To customize, create a new model.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Name
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.name}</p>
|
||||
</div>
|
||||
{config.description && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Description
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{config.description}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Provider
|
||||
</div>
|
||||
<p className="text-sm font-medium">{config.provider}</p>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Model
|
||||
</div>
|
||||
<p className="text-sm font-medium font-mono">{config.model_name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(mode === "create" || (mode === "edit" && !isGlobal)) && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Name *</Label>
|
||||
<Input
|
||||
placeholder="e.g., My GPT-4o Vision"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<Input
|
||||
placeholder="Optional description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, description: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator className="bg-popover-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(val) =>
|
||||
setFormData((p) => ({ ...p, provider: val, model_name: "" }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VISION_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p.value} value={p.value} description={p.example}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Model Name *</Label>
|
||||
<Popover open={modelComboboxOpen} onOpenChange={setModelComboboxOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={modelComboboxOpen}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal bg-transparent",
|
||||
!formData.model_name && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{formData.model_name || "Select a model"}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0" align="start">
|
||||
<Command shouldFilter={false} className="bg-transparent">
|
||||
<CommandInput
|
||||
placeholder={selectedProvider?.example || "Search model name"}
|
||||
value={formData.model_name}
|
||||
onValueChange={(val) => setFormData((p) => ({ ...p, model_name: val }))}
|
||||
/>
|
||||
<CommandList className="max-h-[300px]">
|
||||
<CommandEmpty>
|
||||
<div className="py-3 text-center text-sm text-muted-foreground">
|
||||
{formData.model_name
|
||||
? `Using: "${formData.model_name}"`
|
||||
: "Type your model name"}
|
||||
</div>
|
||||
</CommandEmpty>
|
||||
{availableModels.length > 0 && (
|
||||
<CommandGroup heading="Suggested Models">
|
||||
{availableModels
|
||||
.filter(
|
||||
(model) =>
|
||||
!formData.model_name ||
|
||||
model.value
|
||||
.toLowerCase()
|
||||
.includes(formData.model_name.toLowerCase()) ||
|
||||
model.label
|
||||
.toLowerCase()
|
||||
.includes(formData.model_name.toLowerCase())
|
||||
)
|
||||
.slice(0, 50)
|
||||
.map((model) => (
|
||||
<CommandItem
|
||||
key={model.value}
|
||||
value={model.value}
|
||||
onSelect={(value) => {
|
||||
setFormData((p) => ({
|
||||
...p,
|
||||
model_name: value,
|
||||
}));
|
||||
setModelComboboxOpen(false);
|
||||
}}
|
||||
className="py-2"
|
||||
>
|
||||
<Check
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
formData.model_name === model.value
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
)}
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium">{model.label}</div>
|
||||
{model.contextWindow && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Context: {model.contextWindow}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Key *</Label>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="sk-..."
|
||||
value={formData.api_key}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_key: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Base URL</Label>
|
||||
<Input
|
||||
placeholder={selectedProvider?.apiBase || "Optional"}
|
||||
value={formData.api_base}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_base: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{formData.provider === "AZURE_OPENAI" && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">API Version (Azure)</Label>
|
||||
<Input
|
||||
placeholder="2024-02-15-preview"
|
||||
value={formData.api_version}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, api_version: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 px-6 py-4 flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={isSubmitting}
|
||||
className="text-sm h-9"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
{mode === "create" || (mode === "edit" && !isGlobal) ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !isFormValid}
|
||||
className="relative text-sm h-9 min-w-[120px]"
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>
|
||||
{mode === "edit" ? "Save Changes" : "Add Model"}
|
||||
</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : isGlobal && config ? (
|
||||
<Button
|
||||
className="relative text-sm h-9"
|
||||
onClick={handleUseGlobalConfig}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<span className={isSubmitting ? "opacity-0" : ""}>Use This Model</span>
|
||||
{isSubmitting && <Spinner size="sm" className="absolute" />}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,105 +0,0 @@
|
|||
export interface ImageGenProvider {
|
||||
value: string;
|
||||
label: string;
|
||||
example: string;
|
||||
description: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Image generation providers supported by LiteLLM.
|
||||
* See: https://docs.litellm.ai/docs/image_generation#supported-providers
|
||||
*/
|
||||
export const IMAGE_GEN_PROVIDERS: ImageGenProvider[] = [
|
||||
{
|
||||
value: "OPENAI",
|
||||
label: "OpenAI",
|
||||
example: "dall-e-3, gpt-image-1, dall-e-2",
|
||||
description: "DALL-E and GPT Image models",
|
||||
},
|
||||
{
|
||||
value: "AZURE_OPENAI",
|
||||
label: "Azure OpenAI",
|
||||
example: "azure/dall-e-3, azure/gpt-image-1",
|
||||
description: "OpenAI image models on Azure",
|
||||
},
|
||||
{
|
||||
value: "GOOGLE",
|
||||
label: "Google AI Studio",
|
||||
example: "gemini/imagen-3.0-generate-002",
|
||||
description: "Google AI Studio image generation",
|
||||
},
|
||||
{
|
||||
value: "VERTEX_AI",
|
||||
label: "Google Vertex AI",
|
||||
example: "vertex_ai/imagegeneration@006",
|
||||
description: "Vertex AI image generation models",
|
||||
},
|
||||
{
|
||||
value: "BEDROCK",
|
||||
label: "AWS Bedrock",
|
||||
example: "bedrock/stability.stable-diffusion-xl-v0",
|
||||
description: "Stable Diffusion on AWS Bedrock",
|
||||
},
|
||||
{
|
||||
value: "RECRAFT",
|
||||
label: "Recraft",
|
||||
example: "recraft/recraftv3",
|
||||
description: "AI-powered design and image generation",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "openrouter/google/gemini-2.5-flash-image",
|
||||
description: "Image generation via OpenRouter",
|
||||
},
|
||||
{
|
||||
value: "XINFERENCE",
|
||||
label: "Xinference",
|
||||
example: "xinference/stable-diffusion-xl",
|
||||
description: "Self-hosted Stable Diffusion models",
|
||||
},
|
||||
{
|
||||
value: "NSCALE",
|
||||
label: "Nscale",
|
||||
example: "nscale/flux.1-schnell",
|
||||
description: "Nscale image generation",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Image generation models organized by provider.
|
||||
*/
|
||||
export interface ImageGenModel {
|
||||
value: string;
|
||||
label: string;
|
||||
provider: string;
|
||||
}
|
||||
|
||||
export const IMAGE_GEN_MODELS: ImageGenModel[] = [
|
||||
// OpenAI
|
||||
{ value: "gpt-image-1", label: "GPT Image 1", provider: "OPENAI" },
|
||||
{ value: "dall-e-3", label: "DALL-E 3", provider: "OPENAI" },
|
||||
{ value: "dall-e-2", label: "DALL-E 2", provider: "OPENAI" },
|
||||
// Azure OpenAI
|
||||
{ value: "azure/dall-e-3", label: "DALL-E 3 (Azure)", provider: "AZURE_OPENAI" },
|
||||
{ value: "azure/gpt-image-1", label: "GPT Image 1 (Azure)", provider: "AZURE_OPENAI" },
|
||||
// Recraft
|
||||
{ value: "recraft/recraftv3", label: "Recraft V3", provider: "RECRAFT" },
|
||||
// Bedrock
|
||||
{
|
||||
value: "bedrock/stability.stable-diffusion-xl-v0",
|
||||
label: "Stable Diffusion XL",
|
||||
provider: "BEDROCK",
|
||||
},
|
||||
// Vertex AI
|
||||
{
|
||||
value: "vertex_ai/imagegeneration@006",
|
||||
label: "Imagen 3",
|
||||
provider: "VERTEX_AI",
|
||||
},
|
||||
];
|
||||
|
||||
export function getImageGenModelsByProvider(provider: string): ImageGenModel[] {
|
||||
return IMAGE_GEN_MODELS.filter((m) => m.provider === provider);
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,197 +0,0 @@
|
|||
export interface LLMProvider {
|
||||
value: string;
|
||||
label: string;
|
||||
example: string;
|
||||
description: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export const LLM_PROVIDERS: LLMProvider[] = [
|
||||
{
|
||||
value: "OPENAI",
|
||||
label: "OpenAI",
|
||||
example: "gpt-4o, gpt-4o-mini, o1, o3-mini",
|
||||
description: "Industry-leading GPT models",
|
||||
},
|
||||
{
|
||||
value: "ANTHROPIC",
|
||||
label: "Anthropic",
|
||||
example: "claude-3-5-sonnet, claude-3-opus, claude-4-sonnet",
|
||||
description: "Claude models with strong reasoning",
|
||||
},
|
||||
{
|
||||
value: "GOOGLE",
|
||||
label: "Google (Gemini)",
|
||||
example: "gemini-2.5-flash, gemini-2.5-pro, gemini-1.5-pro",
|
||||
description: "Gemini models with multimodal capabilities",
|
||||
},
|
||||
{
|
||||
value: "AZURE_OPENAI",
|
||||
label: "Azure OpenAI",
|
||||
example: "azure/gpt-4o, azure/gpt-4o-mini",
|
||||
description: "OpenAI models on Azure",
|
||||
},
|
||||
{
|
||||
value: "BEDROCK",
|
||||
label: "AWS Bedrock",
|
||||
example: "anthropic.claude-3-5-sonnet, meta.llama3-70b",
|
||||
description: "Foundation models on AWS",
|
||||
},
|
||||
{
|
||||
value: "VERTEX_AI",
|
||||
label: "Google Vertex AI",
|
||||
example: "vertex_ai/claude-3-5-sonnet, vertex_ai/gemini-2.5-pro",
|
||||
description: "Models on Google Cloud Vertex AI",
|
||||
},
|
||||
{
|
||||
value: "GROQ",
|
||||
label: "Groq",
|
||||
example: "groq/llama-3.3-70b-versatile, groq/mixtral-8x7b",
|
||||
description: "Ultra-fast inference",
|
||||
},
|
||||
{
|
||||
value: "COHERE",
|
||||
label: "Cohere",
|
||||
example: "command-a-03-2025, command-r-plus",
|
||||
description: "Enterprise NLP models",
|
||||
},
|
||||
{
|
||||
value: "MISTRAL",
|
||||
label: "Mistral AI",
|
||||
example: "mistral-large-latest, mistral-medium-latest",
|
||||
description: "European open-source models",
|
||||
},
|
||||
{
|
||||
value: "DEEPSEEK",
|
||||
label: "DeepSeek",
|
||||
example: "deepseek-chat, deepseek-reasoner",
|
||||
description: "High-performance reasoning models",
|
||||
apiBase: "https://api.deepseek.com",
|
||||
},
|
||||
{
|
||||
value: "XAI",
|
||||
label: "xAI (Grok)",
|
||||
example: "grok-4, grok-3, grok-3-mini",
|
||||
description: "Grok models from xAI",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "openrouter/anthropic/claude-4-opus",
|
||||
description: "Unified API for multiple providers",
|
||||
},
|
||||
{
|
||||
value: "TOGETHER_AI",
|
||||
label: "Together AI",
|
||||
example: "together_ai/meta-llama/Llama-3.3-70B-Instruct-Turbo",
|
||||
description: "Fast open-source models",
|
||||
},
|
||||
{
|
||||
value: "FIREWORKS_AI",
|
||||
label: "Fireworks AI",
|
||||
example: "fireworks_ai/accounts/fireworks/models/llama-v3p3-70b-instruct",
|
||||
description: "Scalable inference platform",
|
||||
},
|
||||
{
|
||||
value: "REPLICATE",
|
||||
label: "Replicate",
|
||||
example: "replicate/meta/llama-3-70b-instruct",
|
||||
description: "ML model hosting platform",
|
||||
},
|
||||
{
|
||||
value: "PERPLEXITY",
|
||||
label: "Perplexity",
|
||||
example: "perplexity/sonar-pro, perplexity/sonar-reasoning",
|
||||
description: "Search-augmented models",
|
||||
},
|
||||
{
|
||||
value: "OLLAMA",
|
||||
label: "Ollama",
|
||||
example: "ollama/llama3.1, ollama/mistral",
|
||||
description: "Run models locally",
|
||||
apiBase: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "ALIBABA_QWEN",
|
||||
label: "Alibaba Qwen",
|
||||
example: "dashscope/qwen-plus, dashscope/qwen-turbo",
|
||||
description: "Qwen series models",
|
||||
apiBase: "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
},
|
||||
{
|
||||
value: "MOONSHOT",
|
||||
label: "Moonshot (Kimi)",
|
||||
example: "moonshot/kimi-latest, moonshot/kimi-k2-thinking",
|
||||
description: "Kimi AI models",
|
||||
apiBase: "https://api.moonshot.cn/v1",
|
||||
},
|
||||
{
|
||||
value: "ZHIPU",
|
||||
label: "Zhipu (GLM)",
|
||||
example: "glm-4.6, glm-4.6:exacto",
|
||||
description: "GLM series models",
|
||||
apiBase: "https://open.bigmodel.cn/api/paas/v4",
|
||||
},
|
||||
{
|
||||
value: "ANYSCALE",
|
||||
label: "Anyscale",
|
||||
example: "anyscale/meta-llama/Meta-Llama-3-70B-Instruct",
|
||||
description: "Ray-based inference platform",
|
||||
},
|
||||
{
|
||||
value: "DEEPINFRA",
|
||||
label: "DeepInfra",
|
||||
example: "deepinfra/meta-llama/Meta-Llama-3.3-70B-Instruct",
|
||||
description: "Serverless GPU inference",
|
||||
},
|
||||
{
|
||||
value: "CEREBRAS",
|
||||
label: "Cerebras",
|
||||
example: "cerebras/llama-3.3-70b, cerebras/qwen-3-32b",
|
||||
description: "Fastest inference with Wafer-Scale Engine",
|
||||
},
|
||||
{
|
||||
value: "SAMBANOVA",
|
||||
label: "SambaNova",
|
||||
example: "sambanova/Meta-Llama-3.3-70B-Instruct",
|
||||
description: "AI inference platform",
|
||||
},
|
||||
{
|
||||
value: "AI21",
|
||||
label: "AI21 Labs",
|
||||
example: "jamba-1.5-large, jamba-1.5-mini",
|
||||
description: "Jamba series models",
|
||||
},
|
||||
{
|
||||
value: "CLOUDFLARE",
|
||||
label: "Cloudflare Workers AI",
|
||||
example: "cloudflare/@cf/meta/llama-2-7b-chat",
|
||||
description: "AI on Cloudflare edge network",
|
||||
},
|
||||
{
|
||||
value: "DATABRICKS",
|
||||
label: "Databricks",
|
||||
example: "databricks/databricks-meta-llama-3-3-70b-instruct",
|
||||
description: "Databricks Model Serving",
|
||||
},
|
||||
{
|
||||
value: "GITHUB_MODELS",
|
||||
label: "GitHub Models",
|
||||
example: "openai/gpt-5, meta/llama-3.1-405b-instruct",
|
||||
description: "AI models from GitHub Marketplace",
|
||||
apiBase: "https://models.github.ai/inference",
|
||||
},
|
||||
{
|
||||
value: "MINIMAX",
|
||||
label: "MiniMax",
|
||||
example: "MiniMax-M3, MiniMax-M2.7",
|
||||
description: "High-performance models with up to 512K context",
|
||||
apiBase: "https://api.minimax.io/v1",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
example: "your-custom-model",
|
||||
description: "Custom OpenAI-compatible endpoint",
|
||||
},
|
||||
];
|
||||
|
|
@ -1,168 +0,0 @@
|
|||
import type { LLMModel } from "./llm-models";
|
||||
|
||||
export interface VisionProviderInfo {
|
||||
value: string;
|
||||
label: string;
|
||||
example: string;
|
||||
description: string;
|
||||
apiBase?: string;
|
||||
}
|
||||
|
||||
export const VISION_PROVIDERS: VisionProviderInfo[] = [
|
||||
{
|
||||
value: "OPENAI",
|
||||
label: "OpenAI",
|
||||
example: "gpt-4o, gpt-4o-mini",
|
||||
description: "GPT-4o vision models",
|
||||
},
|
||||
{
|
||||
value: "ANTHROPIC",
|
||||
label: "Anthropic",
|
||||
example: "claude-sonnet-4-20250514",
|
||||
description: "Claude vision models",
|
||||
},
|
||||
{
|
||||
value: "GOOGLE",
|
||||
label: "Google AI Studio",
|
||||
example: "gemini-2.5-flash, gemini-2.0-flash",
|
||||
description: "Gemini vision models",
|
||||
},
|
||||
{
|
||||
value: "AZURE_OPENAI",
|
||||
label: "Azure OpenAI",
|
||||
example: "azure/gpt-4o",
|
||||
description: "OpenAI vision models on Azure",
|
||||
},
|
||||
{
|
||||
value: "VERTEX_AI",
|
||||
label: "Google Vertex AI",
|
||||
example: "vertex_ai/gemini-2.5-flash",
|
||||
description: "Gemini vision models on Vertex AI",
|
||||
},
|
||||
{
|
||||
value: "BEDROCK",
|
||||
label: "AWS Bedrock",
|
||||
example: "bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
|
||||
description: "Vision models on AWS Bedrock",
|
||||
},
|
||||
{
|
||||
value: "XAI",
|
||||
label: "xAI",
|
||||
example: "grok-2-vision",
|
||||
description: "Grok vision models",
|
||||
},
|
||||
{
|
||||
value: "OPENROUTER",
|
||||
label: "OpenRouter",
|
||||
example: "openrouter/openai/gpt-4o",
|
||||
description: "Vision models via OpenRouter",
|
||||
},
|
||||
{
|
||||
value: "OLLAMA",
|
||||
label: "Ollama",
|
||||
example: "llava, bakllava",
|
||||
description: "Local vision models via Ollama",
|
||||
apiBase: "http://localhost:11434",
|
||||
},
|
||||
{
|
||||
value: "GROQ",
|
||||
label: "Groq",
|
||||
example: "llama-4-scout-17b-16e-instruct",
|
||||
description: "Vision models on Groq",
|
||||
},
|
||||
{
|
||||
value: "TOGETHER_AI",
|
||||
label: "Together AI",
|
||||
example: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
||||
description: "Vision models on Together AI",
|
||||
},
|
||||
{
|
||||
value: "FIREWORKS_AI",
|
||||
label: "Fireworks AI",
|
||||
example: "fireworks_ai/phi-3-vision-128k-instruct",
|
||||
description: "Vision models on Fireworks AI",
|
||||
},
|
||||
{
|
||||
value: "DEEPSEEK",
|
||||
label: "DeepSeek",
|
||||
example: "deepseek-chat",
|
||||
description: "DeepSeek vision models",
|
||||
apiBase: "https://api.deepseek.com",
|
||||
},
|
||||
{
|
||||
value: "MISTRAL",
|
||||
label: "Mistral",
|
||||
example: "pixtral-large-latest",
|
||||
description: "Pixtral vision models",
|
||||
},
|
||||
{
|
||||
value: "CUSTOM",
|
||||
label: "Custom Provider",
|
||||
example: "custom/my-vision-model",
|
||||
description: "Custom OpenAI-compatible vision endpoint",
|
||||
},
|
||||
];
|
||||
|
||||
export const VISION_MODELS: LLMModel[] = [
|
||||
{ value: "gpt-4o", label: "GPT-4o", provider: "OPENAI", contextWindow: "128K" },
|
||||
{ value: "gpt-4o-mini", label: "GPT-4o Mini", provider: "OPENAI", contextWindow: "128K" },
|
||||
{ value: "gpt-4-turbo", label: "GPT-4 Turbo", provider: "OPENAI", contextWindow: "128K" },
|
||||
{
|
||||
value: "claude-sonnet-4-20250514",
|
||||
label: "Claude Sonnet 4",
|
||||
provider: "ANTHROPIC",
|
||||
contextWindow: "200K",
|
||||
},
|
||||
{
|
||||
value: "claude-3-7-sonnet-20250219",
|
||||
label: "Claude 3.7 Sonnet",
|
||||
provider: "ANTHROPIC",
|
||||
contextWindow: "200K",
|
||||
},
|
||||
{
|
||||
value: "claude-3-5-sonnet-20241022",
|
||||
label: "Claude 3.5 Sonnet",
|
||||
provider: "ANTHROPIC",
|
||||
contextWindow: "200K",
|
||||
},
|
||||
{
|
||||
value: "claude-3-opus-20240229",
|
||||
label: "Claude 3 Opus",
|
||||
provider: "ANTHROPIC",
|
||||
contextWindow: "200K",
|
||||
},
|
||||
{
|
||||
value: "claude-3-haiku-20240307",
|
||||
label: "Claude 3 Haiku",
|
||||
provider: "ANTHROPIC",
|
||||
contextWindow: "200K",
|
||||
},
|
||||
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
|
||||
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
|
||||
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash", provider: "GOOGLE", contextWindow: "1M" },
|
||||
{ value: "gemini-1.5-pro", label: "Gemini 1.5 Pro", provider: "GOOGLE", contextWindow: "1M" },
|
||||
{ value: "gemini-1.5-flash", label: "Gemini 1.5 Flash", provider: "GOOGLE", contextWindow: "1M" },
|
||||
{
|
||||
value: "pixtral-large-latest",
|
||||
label: "Pixtral Large",
|
||||
provider: "MISTRAL",
|
||||
contextWindow: "128K",
|
||||
},
|
||||
{ value: "pixtral-12b-2409", label: "Pixtral 12B", provider: "MISTRAL", contextWindow: "128K" },
|
||||
{ value: "grok-2-vision-1212", label: "Grok 2 Vision", provider: "XAI", contextWindow: "32K" },
|
||||
{ value: "llava", label: "LLaVA", provider: "OLLAMA" },
|
||||
{ value: "bakllava", label: "BakLLaVA", provider: "OLLAMA" },
|
||||
{ value: "llava-llama3", label: "LLaVA Llama 3", provider: "OLLAMA" },
|
||||
{
|
||||
value: "llama-4-scout-17b-16e-instruct",
|
||||
label: "Llama 4 Scout 17B",
|
||||
provider: "GROQ",
|
||||
contextWindow: "128K",
|
||||
},
|
||||
{
|
||||
value: "meta-llama/Llama-4-Scout-17B-16E-Instruct",
|
||||
label: "Llama 4 Scout 17B",
|
||||
provider: "TOGETHER_AI",
|
||||
contextWindow: "128K",
|
||||
},
|
||||
];
|
||||
|
|
@ -1,476 +0,0 @@
|
|||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* LiteLLM Provider enum - all supported LLM providers
|
||||
*/
|
||||
export const liteLLMProviderEnum = z.enum([
|
||||
"OPENAI",
|
||||
"ANTHROPIC",
|
||||
"GOOGLE",
|
||||
"AZURE_OPENAI",
|
||||
"BEDROCK",
|
||||
"VERTEX_AI",
|
||||
"GROQ",
|
||||
"COHERE",
|
||||
"MISTRAL",
|
||||
"DEEPSEEK",
|
||||
"XAI",
|
||||
"OPENROUTER",
|
||||
"TOGETHER_AI",
|
||||
"FIREWORKS_AI",
|
||||
"REPLICATE",
|
||||
"PERPLEXITY",
|
||||
"OLLAMA",
|
||||
"ALIBABA_QWEN",
|
||||
"MOONSHOT",
|
||||
"ZHIPU",
|
||||
"ANYSCALE",
|
||||
"DEEPINFRA",
|
||||
"CEREBRAS",
|
||||
"SAMBANOVA",
|
||||
"AI21",
|
||||
"CLOUDFLARE",
|
||||
"DATABRICKS",
|
||||
"COMETAPI",
|
||||
"HUGGINGFACE",
|
||||
"GITHUB_MODELS",
|
||||
"MINIMAX",
|
||||
"CUSTOM",
|
||||
]);
|
||||
|
||||
export type LiteLLMProvider = z.infer<typeof liteLLMProviderEnum>;
|
||||
|
||||
/**
|
||||
* NewLLMConfig - combines model settings with prompt configuration
|
||||
*/
|
||||
export const newLLMConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
|
||||
// Model Configuration
|
||||
provider: liteLLMProviderEnum,
|
||||
custom_provider: z.string().max(100).nullable().optional(),
|
||||
model_name: z.string().max(100),
|
||||
api_key: z.string(),
|
||||
api_base: z.string().max(500).nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
|
||||
// Prompt Configuration
|
||||
system_instructions: z.string().default(""),
|
||||
use_default_system_instructions: z.boolean().default(true),
|
||||
citations_enabled: z.boolean().default(true),
|
||||
|
||||
// Metadata
|
||||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
|
||||
// Capability flag — derived server-side at the route boundary from
|
||||
// LiteLLM's authoritative model map. There is no DB column. Default
|
||||
// `true` is the conservative-allow stance for unknown / unmapped
|
||||
// BYOK rows; the streaming-task safety net is the only place a
|
||||
// `false` actually blocks a request.
|
||||
supports_image_input: z.boolean().default(true),
|
||||
});
|
||||
|
||||
/**
|
||||
* Public version without api_key (for list views)
|
||||
*/
|
||||
export const newLLMConfigPublic = newLLMConfig.omit({ api_key: true });
|
||||
|
||||
/**
|
||||
* Create NewLLMConfig
|
||||
*
|
||||
* `supports_image_input` is omitted because it is derived server-side
|
||||
* from LiteLLM's model map at read time — there is no DB column to
|
||||
* persist a client-supplied value into.
|
||||
*/
|
||||
export const createNewLLMConfigRequest = newLLMConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
supports_image_input: true,
|
||||
});
|
||||
|
||||
export const createNewLLMConfigResponse = newLLMConfig;
|
||||
|
||||
/**
|
||||
* Get NewLLMConfigs list
|
||||
*/
|
||||
export const getNewLLMConfigsRequest = z.object({
|
||||
search_space_id: z.number(),
|
||||
skip: z.number().optional(),
|
||||
limit: z.number().optional(),
|
||||
});
|
||||
|
||||
export const getNewLLMConfigsResponse = z.array(newLLMConfig);
|
||||
|
||||
/**
|
||||
* Get single NewLLMConfig
|
||||
*/
|
||||
export const getNewLLMConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
export const getNewLLMConfigResponse = newLLMConfig;
|
||||
|
||||
/**
|
||||
* Update NewLLMConfig
|
||||
*/
|
||||
export const updateNewLLMConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
data: newLLMConfig
|
||||
.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
search_space_id: true,
|
||||
user_id: true,
|
||||
// Derived server-side; not part of the writable surface.
|
||||
supports_image_input: true,
|
||||
})
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateNewLLMConfigResponse = newLLMConfig;
|
||||
|
||||
/**
|
||||
* Delete NewLLMConfig
|
||||
*/
|
||||
export const deleteNewLLMConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
export const deleteNewLLMConfigResponse = z.object({
|
||||
message: z.string(),
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get default system instructions
|
||||
*/
|
||||
export const getDefaultSystemInstructionsResponse = z.object({
|
||||
default_system_instructions: z.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Global NewLLMConfig - from YAML, has negative IDs
|
||||
* ID 0 is reserved for "Auto" mode which uses LiteLLM Router for load balancing
|
||||
*/
|
||||
export const globalNewLLMConfig = z.object({
|
||||
id: z.number(), // 0 for Auto mode, negative IDs for global configs
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
|
||||
// Model Configuration (no api_key)
|
||||
provider: z.string(), // String because YAML doesn't enforce enum, "AUTO" for Auto mode
|
||||
custom_provider: z.string().nullable().optional(),
|
||||
model_name: z.string(),
|
||||
api_base: z.string().nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
|
||||
// Prompt Configuration
|
||||
system_instructions: z.string().default(""),
|
||||
use_default_system_instructions: z.boolean().default(true),
|
||||
citations_enabled: z.boolean().default(true),
|
||||
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false), // True only for Auto mode (ID 0)
|
||||
|
||||
// Token quota and billing policy
|
||||
billing_tier: z.string().default("free"),
|
||||
is_premium: z.boolean().default(false),
|
||||
anonymous_enabled: z.boolean().default(false),
|
||||
seo_enabled: z.boolean().default(false),
|
||||
seo_slug: z.string().nullable().optional(),
|
||||
seo_title: z.string().nullable().optional(),
|
||||
seo_description: z.string().nullable().optional(),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
// Capability flag — true when the model can accept image inputs.
|
||||
// Resolved server-side (OpenRouter dynamic configs use the OR
|
||||
// `architecture.input_modalities` field; YAML / BYOK use LiteLLM's
|
||||
// authoritative `supports_vision` map). The chat selector renders
|
||||
// an amber "No image" hint when this is false and there are
|
||||
// pending image attachments, but does not block selection — the
|
||||
// backend safety net only rejects when LiteLLM *explicitly* marks
|
||||
// the model as text-only, so unknown / new models still flow
|
||||
// through. Default `true` matches that conservative-allow stance.
|
||||
supports_image_input: z.boolean().default(true),
|
||||
});
|
||||
|
||||
export const getGlobalNewLLMConfigsResponse = z.array(globalNewLLMConfig);
|
||||
|
||||
// =============================================================================
|
||||
// Image Generation Config (separate table from NewLLMConfig)
|
||||
// =============================================================================
|
||||
|
||||
/**
|
||||
* ImageGenProvider enum - only providers that support image generation
|
||||
* See: https://docs.litellm.ai/docs/image_generation#supported-providers
|
||||
*/
|
||||
export const imageGenProviderEnum = z.enum([
|
||||
"OPENAI",
|
||||
"AZURE_OPENAI",
|
||||
"GOOGLE",
|
||||
"VERTEX_AI",
|
||||
"BEDROCK",
|
||||
"RECRAFT",
|
||||
"OPENROUTER",
|
||||
"XINFERENCE",
|
||||
"NSCALE",
|
||||
]);
|
||||
|
||||
export type ImageGenProvider = z.infer<typeof imageGenProviderEnum>;
|
||||
|
||||
/**
|
||||
* ImageGenerationConfig - user-created image gen model configs
|
||||
* Separate from NewLLMConfig: no system_instructions, no citations_enabled.
|
||||
*/
|
||||
export const imageGenerationConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
provider: imageGenProviderEnum,
|
||||
custom_provider: z.string().max(100).nullable().optional(),
|
||||
model_name: z.string().max(100),
|
||||
api_key: z.string(),
|
||||
api_base: z.string().max(500).nullable().optional(),
|
||||
api_version: z.string().max(50).nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
});
|
||||
|
||||
export const createImageGenConfigRequest = imageGenerationConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
});
|
||||
|
||||
export const createImageGenConfigResponse = imageGenerationConfig;
|
||||
|
||||
export const getImageGenConfigsResponse = z.array(imageGenerationConfig);
|
||||
|
||||
export const updateImageGenConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
data: imageGenerationConfig
|
||||
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateImageGenConfigResponse = imageGenerationConfig;
|
||||
|
||||
export const deleteImageGenConfigResponse = z.object({
|
||||
message: z.string(),
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Global Image Generation Config - from YAML, has negative IDs
|
||||
* ID 0 is reserved for "Auto" mode (LiteLLM Router load balancing)
|
||||
*/
|
||||
export const globalImageGenConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
provider: z.string(),
|
||||
custom_provider: z.string().nullable().optional(),
|
||||
model_name: z.string(),
|
||||
api_base: z.string().nullable().optional(),
|
||||
api_version: z.string().nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
|
||||
// Free/Premium badge logic lights up automatically for image-gen too.
|
||||
is_premium: z.boolean().default(false),
|
||||
quota_reserve_micros: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getGlobalImageGenConfigsResponse = z.array(globalImageGenConfig);
|
||||
|
||||
// =============================================================================
|
||||
// Vision LLM Config (separate table for vision-capable models)
|
||||
// =============================================================================
|
||||
|
||||
export const visionProviderEnum = z.enum([
|
||||
"OPENAI",
|
||||
"ANTHROPIC",
|
||||
"GOOGLE",
|
||||
"AZURE_OPENAI",
|
||||
"VERTEX_AI",
|
||||
"BEDROCK",
|
||||
"XAI",
|
||||
"OPENROUTER",
|
||||
"OLLAMA",
|
||||
"GROQ",
|
||||
"TOGETHER_AI",
|
||||
"FIREWORKS_AI",
|
||||
"DEEPSEEK",
|
||||
"MISTRAL",
|
||||
"CUSTOM",
|
||||
]);
|
||||
|
||||
export type VisionProvider = z.infer<typeof visionProviderEnum>;
|
||||
|
||||
export const visionLLMConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string().max(100),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
provider: visionProviderEnum,
|
||||
custom_provider: z.string().max(100).nullable().optional(),
|
||||
model_name: z.string().max(100),
|
||||
api_key: z.string(),
|
||||
api_base: z.string().max(500).nullable().optional(),
|
||||
api_version: z.string().max(50).nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
created_at: z.string(),
|
||||
search_space_id: z.number(),
|
||||
user_id: z.string(),
|
||||
});
|
||||
|
||||
export const createVisionLLMConfigRequest = visionLLMConfig.omit({
|
||||
id: true,
|
||||
created_at: true,
|
||||
user_id: true,
|
||||
});
|
||||
|
||||
export const createVisionLLMConfigResponse = visionLLMConfig;
|
||||
|
||||
export const getVisionLLMConfigsResponse = z.array(visionLLMConfig);
|
||||
|
||||
export const updateVisionLLMConfigRequest = z.object({
|
||||
id: z.number(),
|
||||
data: visionLLMConfig
|
||||
.omit({ id: true, created_at: true, search_space_id: true, user_id: true })
|
||||
.partial(),
|
||||
});
|
||||
|
||||
export const updateVisionLLMConfigResponse = visionLLMConfig;
|
||||
|
||||
export const deleteVisionLLMConfigResponse = z.object({
|
||||
message: z.string(),
|
||||
id: z.number(),
|
||||
});
|
||||
|
||||
export const globalVisionLLMConfig = z.object({
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable().optional(),
|
||||
provider: z.string(),
|
||||
custom_provider: z.string().nullable().optional(),
|
||||
model_name: z.string(),
|
||||
api_base: z.string().nullable().optional(),
|
||||
api_version: z.string().nullable().optional(),
|
||||
litellm_params: z.record(z.string(), z.any()).nullable().optional(),
|
||||
is_global: z.literal(true),
|
||||
is_auto_mode: z.boolean().optional().default(false),
|
||||
billing_tier: z.string().default("free"),
|
||||
// Mirrors `globalNewLLMConfig.is_premium` so the new-chat selector's
|
||||
// Free/Premium badge logic lights up automatically for vision too.
|
||||
is_premium: z.boolean().default(false),
|
||||
quota_reserve_tokens: z.number().nullable().optional(),
|
||||
input_cost_per_token: z.number().nullable().optional(),
|
||||
output_cost_per_token: z.number().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getGlobalVisionLLMConfigsResponse = z.array(globalVisionLLMConfig);
|
||||
|
||||
// =============================================================================
|
||||
// LLM Preferences (Role Assignments)
|
||||
// =============================================================================
|
||||
|
||||
export const llmPreferences = z.object({
|
||||
agent_llm_id: z.union([z.number(), z.null()]).optional(),
|
||||
image_generation_config_id: z.union([z.number(), z.null()]).optional(),
|
||||
vision_llm_config_id: z.union([z.number(), z.null()]).optional(),
|
||||
agent_llm: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
image_generation_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
vision_llm_config: z.union([z.record(z.string(), z.unknown()), z.null()]).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Get LLM preferences
|
||||
*/
|
||||
export const getLLMPreferencesRequest = z.object({
|
||||
search_space_id: z.number(),
|
||||
});
|
||||
|
||||
export const getLLMPreferencesResponse = llmPreferences;
|
||||
|
||||
/**
|
||||
* Update LLM preferences
|
||||
*/
|
||||
export const updateLLMPreferencesRequest = z.object({
|
||||
search_space_id: z.number(),
|
||||
data: llmPreferences.pick({
|
||||
agent_llm_id: true,
|
||||
image_generation_config_id: true,
|
||||
vision_llm_config_id: true,
|
||||
}),
|
||||
});
|
||||
|
||||
export const updateLLMPreferencesResponse = llmPreferences;
|
||||
|
||||
// =============================================================================
|
||||
// Model List (dynamic catalogue from OpenRouter API)
|
||||
// =============================================================================
|
||||
|
||||
export const modelListItem = z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
provider: z.string(),
|
||||
context_window: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const getModelListResponse = z.array(modelListItem);
|
||||
|
||||
export type ModelListItem = z.infer<typeof modelListItem>;
|
||||
export type GetModelListResponse = z.infer<typeof getModelListResponse>;
|
||||
|
||||
// =============================================================================
|
||||
// Type Exports
|
||||
// =============================================================================
|
||||
|
||||
export type NewLLMConfig = z.infer<typeof newLLMConfig>;
|
||||
export type NewLLMConfigPublic = z.infer<typeof newLLMConfigPublic>;
|
||||
export type CreateNewLLMConfigRequest = z.infer<typeof createNewLLMConfigRequest>;
|
||||
export type CreateNewLLMConfigResponse = z.infer<typeof createNewLLMConfigResponse>;
|
||||
export type GetNewLLMConfigsRequest = z.infer<typeof getNewLLMConfigsRequest>;
|
||||
export type GetNewLLMConfigsResponse = z.infer<typeof getNewLLMConfigsResponse>;
|
||||
export type GetNewLLMConfigRequest = z.infer<typeof getNewLLMConfigRequest>;
|
||||
export type GetNewLLMConfigResponse = z.infer<typeof getNewLLMConfigResponse>;
|
||||
export type UpdateNewLLMConfigRequest = z.infer<typeof updateNewLLMConfigRequest>;
|
||||
export type UpdateNewLLMConfigResponse = z.infer<typeof updateNewLLMConfigResponse>;
|
||||
export type DeleteNewLLMConfigRequest = z.infer<typeof deleteNewLLMConfigRequest>;
|
||||
export type DeleteNewLLMConfigResponse = z.infer<typeof deleteNewLLMConfigResponse>;
|
||||
export type GetDefaultSystemInstructionsResponse = z.infer<
|
||||
typeof getDefaultSystemInstructionsResponse
|
||||
>;
|
||||
export type GlobalNewLLMConfig = z.infer<typeof globalNewLLMConfig>;
|
||||
export type GetGlobalNewLLMConfigsResponse = z.infer<typeof getGlobalNewLLMConfigsResponse>;
|
||||
export type ImageGenerationConfig = z.infer<typeof imageGenerationConfig>;
|
||||
export type CreateImageGenConfigRequest = z.infer<typeof createImageGenConfigRequest>;
|
||||
export type CreateImageGenConfigResponse = z.infer<typeof createImageGenConfigResponse>;
|
||||
export type GetImageGenConfigsResponse = z.infer<typeof getImageGenConfigsResponse>;
|
||||
export type UpdateImageGenConfigRequest = z.infer<typeof updateImageGenConfigRequest>;
|
||||
export type UpdateImageGenConfigResponse = z.infer<typeof updateImageGenConfigResponse>;
|
||||
export type DeleteImageGenConfigResponse = z.infer<typeof deleteImageGenConfigResponse>;
|
||||
export type GlobalImageGenConfig = z.infer<typeof globalImageGenConfig>;
|
||||
export type GetGlobalImageGenConfigsResponse = z.infer<typeof getGlobalImageGenConfigsResponse>;
|
||||
export type VisionLLMConfig = z.infer<typeof visionLLMConfig>;
|
||||
export type CreateVisionLLMConfigRequest = z.infer<typeof createVisionLLMConfigRequest>;
|
||||
export type CreateVisionLLMConfigResponse = z.infer<typeof createVisionLLMConfigResponse>;
|
||||
export type GetVisionLLMConfigsResponse = z.infer<typeof getVisionLLMConfigsResponse>;
|
||||
export type UpdateVisionLLMConfigRequest = z.infer<typeof updateVisionLLMConfigRequest>;
|
||||
export type UpdateVisionLLMConfigResponse = z.infer<typeof updateVisionLLMConfigResponse>;
|
||||
export type DeleteVisionLLMConfigResponse = z.infer<typeof deleteVisionLLMConfigResponse>;
|
||||
export type GlobalVisionLLMConfig = z.infer<typeof globalVisionLLMConfig>;
|
||||
export type GetGlobalVisionLLMConfigsResponse = z.infer<typeof getGlobalVisionLLMConfigsResponse>;
|
||||
export type LLMPreferences = z.infer<typeof llmPreferences>;
|
||||
export type GetLLMPreferencesRequest = z.infer<typeof getLLMPreferencesRequest>;
|
||||
export type GetLLMPreferencesResponse = z.infer<typeof getLLMPreferencesResponse>;
|
||||
export type UpdateLLMPreferencesRequest = z.infer<typeof updateLLMPreferencesRequest>;
|
||||
export type UpdateLLMPreferencesResponse = z.infer<typeof updateLLMPreferencesResponse>;
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
import {
|
||||
type CreateImageGenConfigRequest,
|
||||
createImageGenConfigRequest,
|
||||
createImageGenConfigResponse,
|
||||
deleteImageGenConfigResponse,
|
||||
getGlobalImageGenConfigsResponse,
|
||||
getImageGenConfigsResponse,
|
||||
type UpdateImageGenConfigRequest,
|
||||
updateImageGenConfigRequest,
|
||||
updateImageGenConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class ImageGenConfigApiService {
|
||||
/**
|
||||
* Get all global image generation configs (from YAML, negative IDs)
|
||||
*/
|
||||
getGlobalConfigs = async () => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/global-image-generation-configs`,
|
||||
getGlobalImageGenConfigsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new image generation config for a search space
|
||||
*/
|
||||
createConfig = async (request: CreateImageGenConfigRequest) => {
|
||||
const parsed = createImageGenConfigRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
return baseApiService.post(`/api/v1/image-generation-configs`, createImageGenConfigResponse, {
|
||||
body: parsed.data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get image generation configs for a search space
|
||||
*/
|
||||
getConfigs = async (searchSpaceId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: String(searchSpaceId),
|
||||
}).toString();
|
||||
return baseApiService.get(
|
||||
`/api/v1/image-generation-configs?${params}`,
|
||||
getImageGenConfigsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing image generation config
|
||||
*/
|
||||
updateConfig = async (request: UpdateImageGenConfigRequest) => {
|
||||
const parsed = updateImageGenConfigRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
const { id, data } = parsed.data;
|
||||
return baseApiService.put(
|
||||
`/api/v1/image-generation-configs/${id}`,
|
||||
updateImageGenConfigResponse,
|
||||
{ body: data }
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an image generation config
|
||||
*/
|
||||
deleteConfig = async (id: number) => {
|
||||
return baseApiService.delete(
|
||||
`/api/v1/image-generation-configs/${id}`,
|
||||
deleteImageGenConfigResponse
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const imageGenConfigApiService = new ImageGenConfigApiService();
|
||||
|
|
@ -1,178 +0,0 @@
|
|||
import {
|
||||
type CreateNewLLMConfigRequest,
|
||||
createNewLLMConfigRequest,
|
||||
createNewLLMConfigResponse,
|
||||
type DeleteNewLLMConfigRequest,
|
||||
deleteNewLLMConfigRequest,
|
||||
deleteNewLLMConfigResponse,
|
||||
type GetNewLLMConfigRequest,
|
||||
type GetNewLLMConfigsRequest,
|
||||
getDefaultSystemInstructionsResponse,
|
||||
getGlobalNewLLMConfigsResponse,
|
||||
getLLMPreferencesResponse,
|
||||
getModelListResponse,
|
||||
getNewLLMConfigRequest,
|
||||
getNewLLMConfigResponse,
|
||||
getNewLLMConfigsRequest,
|
||||
getNewLLMConfigsResponse,
|
||||
type UpdateLLMPreferencesRequest,
|
||||
type UpdateNewLLMConfigRequest,
|
||||
updateLLMPreferencesRequest,
|
||||
updateLLMPreferencesResponse,
|
||||
updateNewLLMConfigRequest,
|
||||
updateNewLLMConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class NewLLMConfigApiService {
|
||||
/**
|
||||
* Get all global NewLLMConfigs available to all users
|
||||
*/
|
||||
getGlobalConfigs = async () => {
|
||||
return baseApiService.get(`/api/v1/global-new-llm-configs`, getGlobalNewLLMConfigsResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get default system instructions template
|
||||
*/
|
||||
getDefaultSystemInstructions = async () => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/new-llm-configs/default-system-instructions`,
|
||||
getDefaultSystemInstructionsResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new NewLLMConfig for a search space
|
||||
*/
|
||||
createConfig = async (request: CreateNewLLMConfigRequest) => {
|
||||
const parsedRequest = createNewLLMConfigRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.post(`/api/v1/new-llm-configs`, createNewLLMConfigResponse, {
|
||||
body: parsedRequest.data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a list of NewLLMConfigs for a search space
|
||||
*/
|
||||
getConfigs = async (request: GetNewLLMConfigsRequest) => {
|
||||
const parsedRequest = getNewLLMConfigsRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
search_space_id: String(parsedRequest.data.search_space_id),
|
||||
...(parsedRequest.data.skip !== undefined && { skip: String(parsedRequest.data.skip) }),
|
||||
...(parsedRequest.data.limit !== undefined && { limit: String(parsedRequest.data.limit) }),
|
||||
}).toString();
|
||||
|
||||
return baseApiService.get(`/api/v1/new-llm-configs?${queryParams}`, getNewLLMConfigsResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a single NewLLMConfig by ID
|
||||
*/
|
||||
getConfig = async (request: GetNewLLMConfigRequest) => {
|
||||
const parsedRequest = getNewLLMConfigRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.get(
|
||||
`/api/v1/new-llm-configs/${parsedRequest.data.id}`,
|
||||
getNewLLMConfigResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an existing NewLLMConfig
|
||||
*/
|
||||
updateConfig = async (request: UpdateNewLLMConfigRequest) => {
|
||||
const parsedRequest = updateNewLLMConfigRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { id, data } = parsedRequest.data;
|
||||
|
||||
return baseApiService.put(`/api/v1/new-llm-configs/${id}`, updateNewLLMConfigResponse, {
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a NewLLMConfig
|
||||
*/
|
||||
deleteConfig = async (request: DeleteNewLLMConfigRequest) => {
|
||||
const parsedRequest = deleteNewLLMConfigRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
return baseApiService.delete(
|
||||
`/api/v1/new-llm-configs/${parsedRequest.data.id}`,
|
||||
deleteNewLLMConfigResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get LLM preferences for a search space
|
||||
*/
|
||||
getLLMPreferences = async (searchSpaceId: number) => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/search-spaces/${searchSpaceId}/llm-preferences`,
|
||||
getLLMPreferencesResponse
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the dynamic model catalogue (sourced from OpenRouter API)
|
||||
*/
|
||||
getModels = async () => {
|
||||
return baseApiService.get(`/api/v1/models`, getModelListResponse);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update LLM preferences for a search space
|
||||
*/
|
||||
updateLLMPreferences = async (request: UpdateLLMPreferencesRequest) => {
|
||||
const parsedRequest = updateLLMPreferencesRequest.safeParse(request);
|
||||
|
||||
if (!parsedRequest.success) {
|
||||
console.error("Invalid request:", parsedRequest.error);
|
||||
const errorMessage = parsedRequest.error.issues.map((issue) => issue.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const { search_space_id, data } = parsedRequest.data;
|
||||
|
||||
return baseApiService.put(
|
||||
`/api/v1/search-spaces/${search_space_id}/llm-preferences`,
|
||||
updateLLMPreferencesResponse,
|
||||
{ body: data }
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export const newLLMConfigApiService = new NewLLMConfigApiService();
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import {
|
||||
type CreateVisionLLMConfigRequest,
|
||||
createVisionLLMConfigRequest,
|
||||
createVisionLLMConfigResponse,
|
||||
deleteVisionLLMConfigResponse,
|
||||
getGlobalVisionLLMConfigsResponse,
|
||||
getModelListResponse,
|
||||
getVisionLLMConfigsResponse,
|
||||
type UpdateVisionLLMConfigRequest,
|
||||
updateVisionLLMConfigRequest,
|
||||
updateVisionLLMConfigResponse,
|
||||
} from "@/contracts/types/new-llm-config.types";
|
||||
import { ValidationError } from "../error";
|
||||
import { baseApiService } from "./base-api.service";
|
||||
|
||||
class VisionLLMConfigApiService {
|
||||
getModels = async () => {
|
||||
return baseApiService.get(`/api/v1/vision-models`, getModelListResponse);
|
||||
};
|
||||
|
||||
getGlobalConfigs = async () => {
|
||||
return baseApiService.get(
|
||||
`/api/v1/global-vision-llm-configs`,
|
||||
getGlobalVisionLLMConfigsResponse
|
||||
);
|
||||
};
|
||||
|
||||
createConfig = async (request: CreateVisionLLMConfigRequest) => {
|
||||
const parsed = createVisionLLMConfigRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
return baseApiService.post(`/api/v1/vision-llm-configs`, createVisionLLMConfigResponse, {
|
||||
body: parsed.data,
|
||||
});
|
||||
};
|
||||
|
||||
getConfigs = async (searchSpaceId: number) => {
|
||||
const params = new URLSearchParams({
|
||||
search_space_id: String(searchSpaceId),
|
||||
}).toString();
|
||||
return baseApiService.get(`/api/v1/vision-llm-configs?${params}`, getVisionLLMConfigsResponse);
|
||||
};
|
||||
|
||||
updateConfig = async (request: UpdateVisionLLMConfigRequest) => {
|
||||
const parsed = updateVisionLLMConfigRequest.safeParse(request);
|
||||
if (!parsed.success) {
|
||||
const msg = parsed.error.issues.map((i) => i.message).join(", ");
|
||||
throw new ValidationError(`Invalid request: ${msg}`);
|
||||
}
|
||||
const { id, data } = parsed.data;
|
||||
return baseApiService.put(`/api/v1/vision-llm-configs/${id}`, updateVisionLLMConfigResponse, {
|
||||
body: data,
|
||||
});
|
||||
};
|
||||
|
||||
deleteConfig = async (id: number) => {
|
||||
return baseApiService.delete(`/api/v1/vision-llm-configs/${id}`, deleteVisionLLMConfigResponse);
|
||||
};
|
||||
}
|
||||
|
||||
export const visionLLMConfigApiService = new VisionLLMConfigApiService();
|
||||
|
|
@ -36,31 +36,12 @@ export const cacheKeys = {
|
|||
withQueryParams: (queries: GetLogsRequest["queryParams"]) =>
|
||||
["logs", "with-query-params", ...stableEntries(queries)] as const,
|
||||
},
|
||||
newLLMConfigs: {
|
||||
all: (searchSpaceId: number) => ["new-llm-configs", searchSpaceId] as const,
|
||||
byId: (configId: number) => ["new-llm-configs", "detail", configId] as const,
|
||||
preferences: (searchSpaceId: number) => ["llm-preferences", searchSpaceId] as const,
|
||||
defaultInstructions: () => ["new-llm-configs", "default-instructions"] as const,
|
||||
global: () => ["new-llm-configs", "global"] as const,
|
||||
modelList: () => ["models", "catalogue"] as const,
|
||||
},
|
||||
modelConnections: {
|
||||
all: (searchSpaceId: number) => ["model-connections", searchSpaceId] as const,
|
||||
global: () => ["model-connections", "global"] as const,
|
||||
providers: () => ["model-connections", "providers"] as const,
|
||||
roles: (searchSpaceId: number) => ["model-roles", searchSpaceId] as const,
|
||||
},
|
||||
imageGenConfigs: {
|
||||
all: (searchSpaceId: number) => ["image-gen-configs", searchSpaceId] as const,
|
||||
byId: (configId: number) => ["image-gen-configs", "detail", configId] as const,
|
||||
global: () => ["image-gen-configs", "global"] as const,
|
||||
},
|
||||
visionLLMConfigs: {
|
||||
all: (searchSpaceId: number) => ["vision-llm-configs", searchSpaceId] as const,
|
||||
byId: (configId: number) => ["vision-llm-configs", "detail", configId] as const,
|
||||
global: () => ["vision-llm-configs", "global"] as const,
|
||||
modelList: () => ["vision-models", "catalogue"] as const,
|
||||
},
|
||||
auth: {
|
||||
user: ["auth", "user"] as const,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -476,9 +476,7 @@
|
|||
"title": "Settings",
|
||||
"subtitle": "Manage your LLM configurations and role assignments for this search space.",
|
||||
"back_to_dashboard": "Back to Dashboard",
|
||||
"model_configs": "Model Configs",
|
||||
"models": "Models",
|
||||
"llm_roles": "LLM Roles",
|
||||
"roles": "Roles",
|
||||
"llm_role_management": "LLM Role Management",
|
||||
"llm_role_desc": "Assign your LLM configurations to specific roles for different purposes.",
|
||||
|
|
@ -746,12 +744,6 @@
|
|||
"nav_models": "Models",
|
||||
"nav_agent_models": "Chat Models",
|
||||
"nav_agent_models_desc": "Models with prompts & citations",
|
||||
"nav_role_assignments": "Role Assignments",
|
||||
"nav_role_assignments_desc": "Assign configs to agent roles",
|
||||
"nav_image_models": "Image Models",
|
||||
"nav_image_models_desc": "Configure image generation models",
|
||||
"nav_vision_models": "Vision Models",
|
||||
"nav_vision_models_desc": "Configure vision-capable LLM models",
|
||||
"nav_system_instructions": "System Instructions",
|
||||
"nav_system_instructions_desc": "SearchSpace-wide AI instructions",
|
||||
"nav_public_links": "Public Chat Links",
|
||||
|
|
|
|||
|
|
@ -476,9 +476,7 @@
|
|||
"title": "Configuración",
|
||||
"subtitle": "Administra tus configuraciones de LLM y asignaciones de roles para este espacio de búsqueda.",
|
||||
"back_to_dashboard": "Volver al panel de control",
|
||||
"model_configs": "Configuraciones de modelos",
|
||||
"models": "Modelos",
|
||||
"llm_roles": "Roles de LLM",
|
||||
"roles": "Roles",
|
||||
"llm_role_management": "Gestión de roles de LLM",
|
||||
"llm_role_desc": "Asigna tus configuraciones de LLM a roles específicos para diferentes propósitos.",
|
||||
|
|
@ -743,14 +741,9 @@
|
|||
"back_to_app": "Volver a la app",
|
||||
"nav_general": "General",
|
||||
"nav_general_desc": "Nombre, descripción e información básica",
|
||||
"nav_models": "Modelos",
|
||||
"nav_agent_models": "Modelos de chat",
|
||||
"nav_agent_models_desc": "Modelos LLM con prompts y citas",
|
||||
"nav_role_assignments": "Asignaciones de roles",
|
||||
"nav_role_assignments_desc": "Asignar configuraciones a roles de agente",
|
||||
"nav_image_models": "Modelos de imagen",
|
||||
"nav_image_models_desc": "Configurar modelos de generación de imágenes",
|
||||
"nav_vision_models": "Modelos de visión",
|
||||
"nav_vision_models_desc": "Configurar modelos LLM con capacidad de visión",
|
||||
"nav_system_instructions": "Instrucciones del sistema",
|
||||
"nav_system_instructions_desc": "Instrucciones de IA a nivel del espacio de búsqueda",
|
||||
"nav_public_links": "Enlaces de chat públicos",
|
||||
|
|
@ -766,7 +759,27 @@
|
|||
"general_reset": "Restablecer cambios",
|
||||
"general_save": "Guardar cambios",
|
||||
"general_saving": "Guardando",
|
||||
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos."
|
||||
"general_unsaved_changes": "Tienes cambios sin guardar. Haz clic en \"Guardar cambios\" para aplicarlos.",
|
||||
"nav_web_search": "Búsqueda web",
|
||||
"nav_web_search_desc": "Configuración de búsqueda web integrada",
|
||||
"web_search_title": "Búsqueda web",
|
||||
"web_search_description": "La búsqueda web funciona con una instancia SearXNG integrada. Todas las consultas se procesan a través de tu servidor; no se envían datos a terceros.",
|
||||
"web_search_enabled_label": "Activar búsqueda web",
|
||||
"web_search_enabled_description": "Cuando está activada, el agente de IA puede buscar en la web información en tiempo real como noticias, precios y eventos actuales.",
|
||||
"web_search_status_healthy": "El servicio de búsqueda web está funcionando",
|
||||
"web_search_status_unhealthy": "El servicio de búsqueda web no está disponible",
|
||||
"web_search_status_not_configured": "El servicio de búsqueda web no está configurado",
|
||||
"web_search_engines_label": "Motores de búsqueda",
|
||||
"web_search_engines_placeholder": "google,brave,duckduckgo",
|
||||
"web_search_engines_description": "Lista separada por comas de motores SearXNG a usar. Déjalo vacío para usar los valores predeterminados.",
|
||||
"web_search_language_label": "Idioma preferido",
|
||||
"web_search_language_placeholder": "es",
|
||||
"web_search_language_description": "Etiqueta de idioma IETF (por ejemplo, es, es-ES). Déjalo vacío para detección automática.",
|
||||
"web_search_safesearch_label": "Nivel de SafeSearch",
|
||||
"web_search_safesearch_description": "0 = desactivado, 1 = moderado, 2 = estricto",
|
||||
"web_search_save": "Guardar configuración de búsqueda web",
|
||||
"web_search_saving": "Guardando...",
|
||||
"web_search_saved": "Configuración de búsqueda web guardada"
|
||||
},
|
||||
"homepage": {
|
||||
"hero_title_part1": "El espacio de trabajo con IA",
|
||||
|
|
|
|||
|
|
@ -476,9 +476,7 @@
|
|||
"title": "सेटिंग्स",
|
||||
"subtitle": "इस सर्च स्पेस के लिए अपनी LLM कॉन्फ़िगरेशन और भूमिका असाइनमेंट प्रबंधित करें।",
|
||||
"back_to_dashboard": "डैशबोर्ड पर वापस जाएं",
|
||||
"model_configs": "मॉडल कॉन्फ़िगरेशन",
|
||||
"models": "मॉडल",
|
||||
"llm_roles": "LLM भूमिकाएं",
|
||||
"roles": "भूमिकाएं",
|
||||
"llm_role_management": "LLM भूमिका प्रबंधन",
|
||||
"llm_role_desc": "विभिन्न उद्देश्यों के लिए अपनी LLM कॉन्फ़िगरेशन को विशिष्ट भूमिकाओं में असाइन करें।",
|
||||
|
|
@ -743,14 +741,9 @@
|
|||
"back_to_app": "ऐप पर वापस जाएं",
|
||||
"nav_general": "सामान्य",
|
||||
"nav_general_desc": "नाम, विवरण और बुनियादी जानकारी",
|
||||
"nav_models": "मॉडल",
|
||||
"nav_agent_models": "चैट मॉडल",
|
||||
"nav_agent_models_desc": "प्रॉम्प्ट और उद्धरण के साथ LLM मॉडल",
|
||||
"nav_role_assignments": "भूमिका असाइनमेंट",
|
||||
"nav_role_assignments_desc": "एजेंट भूमिकाओं को कॉन्फ़िगरेशन असाइन करें",
|
||||
"nav_image_models": "इमेज मॉडल",
|
||||
"nav_image_models_desc": "इमेज जनरेशन मॉडल कॉन्फ़िगर करें",
|
||||
"nav_vision_models": "विज़न मॉडल",
|
||||
"nav_vision_models_desc": "विज़न-सक्षम LLM मॉडल कॉन्फ़िगर करें",
|
||||
"nav_system_instructions": "सिस्टम निर्देश",
|
||||
"nav_system_instructions_desc": "सर्च स्पेस-व्यापी AI निर्देश",
|
||||
"nav_public_links": "सार्वजनिक चैट लिंक",
|
||||
|
|
@ -766,7 +759,27 @@
|
|||
"general_reset": "परिवर्तन रीसेट करें",
|
||||
"general_save": "परिवर्तन सहेजें",
|
||||
"general_saving": "सहेजा जा रहा है",
|
||||
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।"
|
||||
"general_unsaved_changes": "आपके पास सहेजे नहीं गए परिवर्तन हैं। उन्हें लागू करने के लिए \"परिवर्तन सहेजें\" पर क्लिक करें।",
|
||||
"nav_web_search": "वेब खोज",
|
||||
"nav_web_search_desc": "बिल्ट-इन वेब खोज सेटिंग्स",
|
||||
"web_search_title": "वेब खोज",
|
||||
"web_search_description": "वेब खोज एक बिल्ट-इन SearXNG इंस्टेंस द्वारा संचालित है। सभी क्वेरी आपके सर्वर के माध्यम से प्रॉक्सी की जाती हैं; कोई डेटा तृतीय पक्षों को नहीं भेजा जाता।",
|
||||
"web_search_enabled_label": "वेब खोज सक्षम करें",
|
||||
"web_search_enabled_description": "सक्षम होने पर, AI एजेंट समाचार, कीमतों और वर्तमान घटनाओं जैसी वास्तविक समय की जानकारी के लिए वेब खोज सकता है।",
|
||||
"web_search_status_healthy": "वेब खोज सेवा स्वस्थ है",
|
||||
"web_search_status_unhealthy": "वेब खोज सेवा उपलब्ध नहीं है",
|
||||
"web_search_status_not_configured": "वेब खोज सेवा कॉन्फ़िगर नहीं है",
|
||||
"web_search_engines_label": "खोज इंजन",
|
||||
"web_search_engines_placeholder": "google,brave,duckduckgo",
|
||||
"web_search_engines_description": "उपयोग करने के लिए SearXNG इंजनों की कॉमा-सेपरेटेड सूची। डिफ़ॉल्ट के लिए खाली छोड़ें।",
|
||||
"web_search_language_label": "पसंदीदा भाषा",
|
||||
"web_search_language_placeholder": "hi",
|
||||
"web_search_language_description": "IETF भाषा टैग (जैसे hi, hi-IN)। ऑटो-डिटेक्ट के लिए खाली छोड़ें।",
|
||||
"web_search_safesearch_label": "SafeSearch स्तर",
|
||||
"web_search_safesearch_description": "0 = बंद, 1 = मध्यम, 2 = सख्त",
|
||||
"web_search_save": "वेब खोज सेटिंग्स सहेजें",
|
||||
"web_search_saving": "सहेजा जा रहा है...",
|
||||
"web_search_saved": "वेब खोज सेटिंग्स सहेजी गईं"
|
||||
},
|
||||
"homepage": {
|
||||
"hero_title_part1": "AI कार्यक्षेत्र",
|
||||
|
|
|
|||
|
|
@ -476,9 +476,7 @@
|
|||
"title": "Configurações",
|
||||
"subtitle": "Gerencie suas configurações de LLM e atribuições de funções para este espaço de pesquisa.",
|
||||
"back_to_dashboard": "Voltar ao painel",
|
||||
"model_configs": "Configurações de modelos",
|
||||
"models": "Modelos",
|
||||
"llm_roles": "Funções de LLM",
|
||||
"roles": "Funções",
|
||||
"llm_role_management": "Gerenciamento de funções de LLM",
|
||||
"llm_role_desc": "Atribua suas configurações de LLM a funções específicas para diferentes propósitos.",
|
||||
|
|
@ -743,14 +741,9 @@
|
|||
"back_to_app": "Voltar ao app",
|
||||
"nav_general": "Geral",
|
||||
"nav_general_desc": "Nome, descrição e informações básicas",
|
||||
"nav_models": "Modelos",
|
||||
"nav_agent_models": "Modelos de chat",
|
||||
"nav_agent_models_desc": "Modelos LLM com prompts e citações",
|
||||
"nav_role_assignments": "Atribuições de funções",
|
||||
"nav_role_assignments_desc": "Atribuir configurações a funções do agente",
|
||||
"nav_image_models": "Modelos de imagem",
|
||||
"nav_image_models_desc": "Configurar modelos de geração de imagens",
|
||||
"nav_vision_models": "Modelos de visão",
|
||||
"nav_vision_models_desc": "Configurar modelos LLM com capacidade de visão",
|
||||
"nav_system_instructions": "Instruções do sistema",
|
||||
"nav_system_instructions_desc": "Instruções de IA em nível do espaço de pesquisa",
|
||||
"nav_public_links": "Links de chat públicos",
|
||||
|
|
@ -766,7 +759,27 @@
|
|||
"general_reset": "Redefinir alterações",
|
||||
"general_save": "Salvar alterações",
|
||||
"general_saving": "Salvando",
|
||||
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las."
|
||||
"general_unsaved_changes": "Você tem alterações não salvas. Clique em \"Salvar alterações\" para aplicá-las.",
|
||||
"nav_web_search": "Pesquisa na web",
|
||||
"nav_web_search_desc": "Configurações integradas de pesquisa na web",
|
||||
"web_search_title": "Pesquisa na web",
|
||||
"web_search_description": "A pesquisa na web é alimentada por uma instância SearXNG integrada. Todas as consultas passam pelo seu servidor; nenhum dado é enviado a terceiros.",
|
||||
"web_search_enabled_label": "Ativar pesquisa na web",
|
||||
"web_search_enabled_description": "Quando ativado, o agente de IA pode pesquisar na web informações em tempo real, como notícias, preços e eventos atuais.",
|
||||
"web_search_status_healthy": "O serviço de pesquisa na web está saudável",
|
||||
"web_search_status_unhealthy": "O serviço de pesquisa na web está indisponível",
|
||||
"web_search_status_not_configured": "O serviço de pesquisa na web não está configurado",
|
||||
"web_search_engines_label": "Mecanismos de pesquisa",
|
||||
"web_search_engines_placeholder": "google,brave,duckduckgo",
|
||||
"web_search_engines_description": "Lista separada por vírgulas de mecanismos SearXNG a usar. Deixe vazio para os padrões.",
|
||||
"web_search_language_label": "Idioma preferido",
|
||||
"web_search_language_placeholder": "pt",
|
||||
"web_search_language_description": "Tag de idioma IETF (por exemplo, pt, pt-BR). Deixe vazio para detecção automática.",
|
||||
"web_search_safesearch_label": "Nível de SafeSearch",
|
||||
"web_search_safesearch_description": "0 = desativado, 1 = moderado, 2 = rigoroso",
|
||||
"web_search_save": "Salvar configurações de pesquisa na web",
|
||||
"web_search_saving": "Salvando...",
|
||||
"web_search_saved": "Configurações de pesquisa na web salvas"
|
||||
},
|
||||
"homepage": {
|
||||
"hero_title_part1": "O espaço de trabalho com IA",
|
||||
|
|
|
|||
|
|
@ -96,6 +96,10 @@
|
|||
"create_new_search_space": "创建新的搜索空间",
|
||||
"delete_title": "删除搜索空间",
|
||||
"delete_confirm": "您确定要删除「{name}」吗?此操作无法撤销,将永久删除所有数据。",
|
||||
"leave": "退出",
|
||||
"leave_title": "退出搜索空间",
|
||||
"leave_confirm": "您确定要退出「{name}」吗?您将无法访问此搜索空间中的所有文档和对话。",
|
||||
"leaving": "退出中...",
|
||||
"welcome_title": "欢迎使用 SurfSense",
|
||||
"welcome_description": "创建您的第一个搜索空间,开始组织知识、连接数据源并与AI对话。",
|
||||
"create_first_button": "创建第一个搜索空间"
|
||||
|
|
@ -104,6 +108,17 @@
|
|||
"title": "用户设置",
|
||||
"description": "管理您的账户设置和API访问",
|
||||
"back_to_app": "返回应用",
|
||||
"profile_nav_label": "个人资料",
|
||||
"profile_nav_description": "管理您的显示名称和头像",
|
||||
"profile_title": "个人资料",
|
||||
"profile_description": "更新您的个人信息",
|
||||
"profile_avatar": "个人头像",
|
||||
"profile_display_name": "显示名称",
|
||||
"profile_display_name_hint": "这是您的名称在应用中的显示方式",
|
||||
"profile_email": "电子邮件",
|
||||
"profile_save": "保存更改",
|
||||
"profile_saved": "个人资料已成功更新",
|
||||
"profile_save_error": "无法更新个人资料",
|
||||
"api_key_nav_label": "API密钥",
|
||||
"api_key_nav_description": "管理您的API访问令牌",
|
||||
"api_key_title": "API密钥",
|
||||
|
|
@ -460,9 +475,7 @@
|
|||
"title": "设置",
|
||||
"subtitle": "管理此搜索空间的 LLM 配置和角色分配。",
|
||||
"back_to_dashboard": "返回仪表盘",
|
||||
"model_configs": "模型配置",
|
||||
"models": "模型",
|
||||
"llm_roles": "LLM 角色",
|
||||
"roles": "角色",
|
||||
"llm_role_management": "LLM 角色管理",
|
||||
"llm_role_desc": "为不同用途分配您的 LLM 配置到特定角色。",
|
||||
|
|
@ -727,14 +740,9 @@
|
|||
"back_to_app": "返回应用",
|
||||
"nav_general": "常规",
|
||||
"nav_general_desc": "名称、描述和基本信息",
|
||||
"nav_models": "模型",
|
||||
"nav_agent_models": "聊天模型",
|
||||
"nav_agent_models_desc": "LLM 模型配置提示词和引用",
|
||||
"nav_role_assignments": "角色分配",
|
||||
"nav_role_assignments_desc": "为代理角色分配配置",
|
||||
"nav_image_models": "图像模型",
|
||||
"nav_image_models_desc": "配置图像生成模型",
|
||||
"nav_vision_models": "视觉模型",
|
||||
"nav_vision_models_desc": "配置具有视觉能力的LLM模型",
|
||||
"nav_system_instructions": "系统指令",
|
||||
"nav_system_instructions_desc": "搜索空间级别的 AI 指令",
|
||||
"nav_public_links": "公开聊天链接",
|
||||
|
|
@ -750,7 +758,27 @@
|
|||
"general_reset": "重置更改",
|
||||
"general_save": "保存更改",
|
||||
"general_saving": "保存中...",
|
||||
"general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。"
|
||||
"general_unsaved_changes": "您有未保存的更改。点击\"保存更改\"以应用它们。",
|
||||
"nav_web_search": "网页搜索",
|
||||
"nav_web_search_desc": "内置网页搜索设置",
|
||||
"web_search_title": "网页搜索",
|
||||
"web_search_description": "网页搜索由内置 SearXNG 实例提供支持。所有查询都通过您的服务器代理;不会向第三方发送数据。",
|
||||
"web_search_enabled_label": "启用网页搜索",
|
||||
"web_search_enabled_description": "启用后,AI 代理可以搜索网页以获取新闻、价格和当前事件等实时信息。",
|
||||
"web_search_status_healthy": "网页搜索服务运行正常",
|
||||
"web_search_status_unhealthy": "网页搜索服务不可用",
|
||||
"web_search_status_not_configured": "网页搜索服务未配置",
|
||||
"web_search_engines_label": "搜索引擎",
|
||||
"web_search_engines_placeholder": "google,brave,duckduckgo",
|
||||
"web_search_engines_description": "要使用的 SearXNG 引擎的逗号分隔列表。留空则使用默认值。",
|
||||
"web_search_language_label": "首选语言",
|
||||
"web_search_language_placeholder": "zh",
|
||||
"web_search_language_description": "IETF 语言标签(例如 zh、zh-CN)。留空则自动检测。",
|
||||
"web_search_safesearch_label": "SafeSearch 级别",
|
||||
"web_search_safesearch_description": "0 = 关闭,1 = 中等,2 = 严格",
|
||||
"web_search_save": "保存网页搜索设置",
|
||||
"web_search_saving": "保存中...",
|
||||
"web_search_saved": "网页搜索设置已保存"
|
||||
},
|
||||
"homepage": {
|
||||
"hero_title_part1": "AI 工作空间",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue