feat: implement AI usage dashboard and provider management features for tenant administration
This commit is contained in:
parent
c427e4aa51
commit
1d207d2dcb
@ -197,15 +197,25 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
{
|
||||
label: "Tenant Config",
|
||||
path: "/tenant/ai/config",
|
||||
label: "Tenant AI Providers",
|
||||
path: "/tenant/ai/providers",
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
{
|
||||
label: "Knowledge (RAG)",
|
||||
path: "/tenant/ai/knowledge",
|
||||
label: "AI Usage & Cost Dashboard",
|
||||
path: "/tenant/ai/dashboard",
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
// {
|
||||
// label: "Tenant Config",
|
||||
// path: "/tenant/ai/config",
|
||||
// requiredPermission: { resource: "ai" },
|
||||
// },
|
||||
// {
|
||||
// label: "Knowledge (RAG)",
|
||||
// path: "/tenant/ai/knowledge",
|
||||
// requiredPermission: { resource: "ai" },
|
||||
// },
|
||||
],
|
||||
requiredPermission: { resource: "ai" },
|
||||
},
|
||||
|
||||
140
src/components/tenant/ViewAIProviderModal.tsx
Normal file
140
src/components/tenant/ViewAIProviderModal.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
import type { ReactElement } from "react";
|
||||
import { Modal } from "@/components/shared";
|
||||
import type { TenantAIConfig } from "@/types/ai";
|
||||
|
||||
interface ViewAIProviderModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
config: TenantAIConfig | null;
|
||||
}
|
||||
|
||||
export const ViewAIProviderModal = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
config,
|
||||
}: ViewAIProviderModalProps): ReactElement => {
|
||||
if (!config) return <></>;
|
||||
|
||||
const parseArray = (val: any): string[] => {
|
||||
if (!val) return [];
|
||||
if (Array.isArray(val)) return val;
|
||||
if (typeof val === "string") {
|
||||
return val.split(",").map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="AI Provider Details"
|
||||
description="View detailed settings for this AI Provider configuration."
|
||||
maxWidth="lg"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4 text-sm select-none p-3">
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||
Provider
|
||||
</span>
|
||||
<span className="text-slate-800 font-medium">
|
||||
{config.provider}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||
Display Name
|
||||
</span>
|
||||
<span className="text-slate-800 font-medium">
|
||||
{config.display_name || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||
Config Type
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold uppercase tracking-wider bg-blue-50 text-blue-600 border border-blue-100 mt-1">
|
||||
{config.config_type || "direct"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||
Default Model
|
||||
</span>
|
||||
<span className="text-slate-800 font-medium">
|
||||
{config.default_model || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||
Default Embedding Model
|
||||
</span>
|
||||
<span className="text-slate-800 font-medium">
|
||||
{(config as any).default_embedding_model || "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{config.endpoint && (
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-0.5">
|
||||
Endpoint URL
|
||||
</span>
|
||||
<span className="text-slate-800 font-mono break-all select-all">
|
||||
{config.endpoint}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||
Custom Models
|
||||
</span>
|
||||
{parseArray(config.custom_models).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{parseArray(config.custom_models).map((m: any, idx: any) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-slate-50 text-slate-700 rounded text-xs font-medium border border-slate-200"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-800 font-medium">—</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-2">
|
||||
<span className="text-xs text-slate-400 font-semibold uppercase tracking-wider block mb-1">
|
||||
Custom Embedding Models
|
||||
</span>
|
||||
{parseArray((config as any).custom_embedding_models).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{parseArray((config as any).custom_embedding_models).map((m: any, idx: any) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="px-2 py-0.5 bg-slate-50 text-slate-700 rounded text-xs font-medium border border-slate-200"
|
||||
>
|
||||
{m}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-800 font-medium">—</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <div className="flex justify-end pt-4 border-t border-slate-100 mt-4">
|
||||
<PrimaryButton onClick={onClose} className="h-10 px-5">
|
||||
Close
|
||||
</PrimaryButton>
|
||||
</div> */}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
342
src/pages/tenant/TenantAIDashboard.tsx
Normal file
342
src/pages/tenant/TenantAIDashboard.tsx
Normal file
@ -0,0 +1,342 @@
|
||||
import { useEffect, useState, type ReactElement } from "react";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import { DataTable, type Column, FilterDropdown } from "@/components/shared";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import type { AICostSummary } from "@/types/ai";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import {
|
||||
Calendar,
|
||||
// RefreshCw,
|
||||
MessageSquare,
|
||||
Coins,
|
||||
DollarSign,
|
||||
Timer,
|
||||
} from "lucide-react";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
|
||||
type GroupBy = "day" | "week" | "month";
|
||||
|
||||
export const TenantAIDashboard = (): ReactElement => {
|
||||
const [groupBy, setGroupBy] = useState<GroupBy | null>(null);
|
||||
const [startDate, setStartDate] = useState<string>("");
|
||||
const [endDate, setEndDate] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [costs, setCosts] = useState<AICostSummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupBy) {
|
||||
setStartDate("");
|
||||
setEndDate("");
|
||||
return;
|
||||
}
|
||||
const now = new Date();
|
||||
const start = new Date();
|
||||
if (groupBy === "day") {
|
||||
start.setDate(now.getDate() - 1);
|
||||
} else if (groupBy === "week") {
|
||||
start.setDate(now.getDate() - 7);
|
||||
} else if (groupBy === "month") {
|
||||
start.setDate(now.getDate() - 30);
|
||||
}
|
||||
setStartDate(start.toISOString().split("T")[0]);
|
||||
setEndDate(now.toISOString().split("T")[0]);
|
||||
}, [groupBy]);
|
||||
|
||||
const fetchCostSummary = async (
|
||||
group: GroupBy | null = groupBy,
|
||||
start: string = startDate,
|
||||
end: string = endDate,
|
||||
) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await aiService.getCostSummary({
|
||||
group_by: group || undefined,
|
||||
start_date: group && start ? `${start}T00:00:00.000Z` : undefined,
|
||||
end_date: group && end ? `${end}T23:59:59.999Z` : undefined,
|
||||
});
|
||||
setCosts(data);
|
||||
} catch (err: any) {
|
||||
showToast.error(
|
||||
err?.response?.data?.message || "Failed to fetch cost summary",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!groupBy || (startDate && endDate)) {
|
||||
void fetchCostSummary(groupBy, startDate, endDate);
|
||||
}
|
||||
}, [groupBy, startDate, endDate]);
|
||||
|
||||
const providerColumns: Column<any>[] = [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
render: (row) => (
|
||||
<span className="font-semibold text-slate-900 select-all">
|
||||
{row.provider}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "completions",
|
||||
label: "Requests",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<span className="font-medium text-slate-700">{row.completions}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tokens",
|
||||
label: "Tokens",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<span className="font-mono text-xs text-slate-600">{row.tokens}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
label: "Cost (USD)",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<span className="font-mono text-xs text-emerald-600 font-bold">
|
||||
${(row.cost || 0).toFixed(6)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const modelColumns: Column<any>[] = [
|
||||
{
|
||||
key: "model",
|
||||
label: "Model",
|
||||
render: (row) => (
|
||||
<div className="flex flex-col">
|
||||
<span className="font-semibold text-slate-900 select-all">
|
||||
{row.model}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-medium uppercase tracking-wider">
|
||||
{row.provider}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "completions",
|
||||
label: "Requests",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<span className="font-medium text-slate-700">{row.completions}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "tokens",
|
||||
label: "Tokens",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<span className="font-mono text-xs text-slate-600">{row.tokens}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "cost",
|
||||
label: "Cost (USD)",
|
||||
align: "right",
|
||||
render: (row) => (
|
||||
<span className="font-mono text-xs text-emerald-600 font-bold">
|
||||
${(row.cost || 0).toFixed(6)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Formatting helpers
|
||||
const formatTokens = (t: number) => {
|
||||
if (!t) return "0";
|
||||
if (t >= 1000000) return `${(t / 1000000).toFixed(2)}M`;
|
||||
if (t >= 1000) return `${(t / 1000).toFixed(1)}K`;
|
||||
return t.toString();
|
||||
};
|
||||
|
||||
const formatLatency = (ms: number) => {
|
||||
if (!ms) return "0ms";
|
||||
if (ms >= 1000) return `${(ms / 1000).toFixed(2)}s`;
|
||||
return `${Math.round(ms)}ms`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="AI Services"
|
||||
pageHeader={{
|
||||
title: "AI Usage & Cost Dashboard",
|
||||
description:
|
||||
"Monitor AI completions, tokens, and billing analytics across all providers.",
|
||||
}}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Top Controls Row */}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row items-start gap-3">
|
||||
{/* Date Range */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={startDate && endDate ? `${formatDate(startDate)} - ${formatDate(endDate)}` : "All time"}
|
||||
className="w-full sm:w-[220px] h-11 px-4 pr-10 rounded-[8px] border border-[#D1D5DB] bg-white text-[14px] font-medium text-[#334155] outline-none cursor-pointer"
|
||||
/>
|
||||
<Calendar className="w-4 h-4 text-[#475569] absolute right-4 top-1/2 -translate-y-1/2 pointer-events-none" />
|
||||
</div>
|
||||
|
||||
{/* Group By */}
|
||||
<div className="w-full sm:w-[240px]">
|
||||
<FilterDropdown
|
||||
label="Group by"
|
||||
options={[
|
||||
{ value: "day", label: "Day" },
|
||||
{ value: "week", label: "Week" },
|
||||
{ value: "month", label: "Month" },
|
||||
]}
|
||||
value={groupBy}
|
||||
onChange={(val) => {
|
||||
setGroupBy(val as GroupBy | null);
|
||||
}}
|
||||
placeholder="All"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 gap-4 select-none">
|
||||
{/* Card 1 */}
|
||||
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||
<div className="text-[#94A3B8]">
|
||||
<MessageSquare className="w-4 h-4 stroke-[1.8]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||
{costs?.summary?.total_completions || 0}
|
||||
</h4>
|
||||
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||
Total Completions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 2 */}
|
||||
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||
<div className="text-[#94A3B8]">
|
||||
<Coins className="w-4 h-4 stroke-[1.8]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||
{formatTokens(costs?.summary?.total_tokens || 0)}
|
||||
</h4>
|
||||
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||
Total Tokens
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 3 */}
|
||||
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||
<div className="text-[#94A3B8]">
|
||||
<DollarSign className="w-4 h-4 stroke-[1.8]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||
${(costs?.summary?.total_cost || 0).toFixed(2)}
|
||||
</h4>
|
||||
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||
Total Cost (USD)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 4 */}
|
||||
<div className="rounded-[8px] bg-gradient-to-r from-[#3B82F6] via-[#22C55E] to-[#EAB308] p-[1px]">
|
||||
<div className="flex flex-col items-start gap-3 px-4 py-3 min-h-[108px] rounded-[7px] bg-[#F8FAFC]">
|
||||
<div className="text-[#94A3B8]">
|
||||
<Timer className="w-4 h-4 stroke-[1.8]" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-[18px] leading-[24px] font-semibold text-[#1E293B] tracking-[-0.02em]">
|
||||
{formatLatency(costs?.summary?.avg_latency_ms || 0)}
|
||||
</h4>
|
||||
<p className="text-[14px] leading-[20px] font-normal text-[#64748B]">
|
||||
Avg Latency (ms)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Charts and Tables */}
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Usage by Provider Section */}
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-800 select-none">
|
||||
Usage by Provider
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 select-none">
|
||||
Breakdown of requests, tokens and usage costs for each distinct
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-50 rounded-xl overflow-hidden">
|
||||
<DataTable
|
||||
data={costs?.by_provider || []}
|
||||
columns={providerColumns}
|
||||
keyExtractor={(item) =>
|
||||
item.provider || Math.random().toString()
|
||||
}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No provider metrics available for this period."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost by Model Section */}
|
||||
<div className="bg-white border border-slate-100 rounded-xl shadow-sm p-5 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-slate-800 select-none">
|
||||
Cost by Model
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400 select-none">
|
||||
Breakdown of requests and costs grouped at the AI model level.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="border border-slate-50 rounded-xl overflow-hidden">
|
||||
<DataTable
|
||||
data={costs?.by_model || []}
|
||||
columns={modelColumns}
|
||||
keyExtractor={(item) =>
|
||||
`${item.model}-${item.provider}` || Math.random().toString()
|
||||
}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="No model metrics available for this period."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantAIDashboard;
|
||||
377
src/pages/tenant/TenantAIProviderCreate.tsx
Normal file
377
src/pages/tenant/TenantAIProviderCreate.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
import { type ReactElement, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
FormField,
|
||||
PrimaryButton,
|
||||
SecondaryButton,
|
||||
FormTagInput,
|
||||
} from "@/components/shared";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { useForm, Controller } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import { showToast } from "@/utils/toast";
|
||||
|
||||
const createConfigSchema = z.object({
|
||||
provider: z.string().min(1, "Provider vendor is required"),
|
||||
display_name: z.string().optional(),
|
||||
config_type: z.enum(["direct", "azure"]),
|
||||
is_active: z.boolean().default(true),
|
||||
api_key: z.string().min(1, "API Key is required"),
|
||||
endpoint: z.string().url().max(500).optional(),
|
||||
deployment: z.string().optional(),
|
||||
api_version: z.string().optional(),
|
||||
default_model: z.string().min(1, "Default model is required"),
|
||||
custom_models: z.array(z.string()).default([]),
|
||||
default_embedding_model: z.string().optional(),
|
||||
custom_embedding_models: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const TenantAIProviderCreate = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
|
||||
const {
|
||||
control,
|
||||
handleSubmit,
|
||||
watch,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm<any>({
|
||||
resolver: zodResolver(createConfigSchema),
|
||||
defaultValues: {
|
||||
provider: "",
|
||||
display_name: "",
|
||||
config_type: "direct",
|
||||
is_active: true,
|
||||
api_key: "",
|
||||
endpoint: "",
|
||||
deployment: "",
|
||||
api_version: "",
|
||||
default_model: "",
|
||||
custom_models: [],
|
||||
default_embedding_model: "",
|
||||
custom_embedding_models: [],
|
||||
},
|
||||
});
|
||||
|
||||
const selectedConfigType = watch("config_type");
|
||||
|
||||
const onFormSubmit = async (data: any) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await aiService.upsertConfig({
|
||||
provider: data.provider,
|
||||
display_name: data.display_name || undefined,
|
||||
config_type: data.config_type,
|
||||
is_active: data.is_active,
|
||||
api_key: data.api_key,
|
||||
endpoint: data.endpoint || undefined,
|
||||
deployment: data.deployment || undefined,
|
||||
api_version: data.api_version || undefined,
|
||||
default_model: data.default_model,
|
||||
custom_models: data.custom_models || [],
|
||||
default_embedding_model: data.default_embedding_model || undefined,
|
||||
custom_embedding_models: data.custom_embedding_models || [],
|
||||
} as any);
|
||||
|
||||
showToast.success("AI Provider configuration created successfully!");
|
||||
navigate("/tenant/ai/providers");
|
||||
} catch (err: any) {
|
||||
if (err?.response?.data?.details && Array.isArray(err.response.data.details)) {
|
||||
err.response.data.details.forEach((detail: any) => {
|
||||
if (detail.path) {
|
||||
setError(detail.path, { type: "server", message: detail.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
const msg =
|
||||
err?.response?.data?.message ||
|
||||
err?.response?.data?.error ||
|
||||
"Failed to save AI Provider configuration.";
|
||||
showToast.error(typeof msg === "string" ? msg : "Validation failed");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="AI Gateway Services"
|
||||
pageHeader={{
|
||||
title: "AI Provider Configuration",
|
||||
description: "Manage and reuse prompts for different use cases.",
|
||||
action: (
|
||||
<div className="flex items-center gap-2">
|
||||
<SecondaryButton
|
||||
onClick={() => navigate("/tenant/ai/providers")}
|
||||
className="h-10 px-5 min-w-[120px]"
|
||||
>
|
||||
Cancel
|
||||
</SecondaryButton>
|
||||
<PrimaryButton
|
||||
onClick={handleSubmit(onFormSubmit)}
|
||||
disabled={isSubmitting}
|
||||
className="h-10 px-5 min-w-[120px]"
|
||||
>
|
||||
{isSubmitting ? "Saving..." : "Save Configuration"}
|
||||
</PrimaryButton>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="mb-4">
|
||||
<button
|
||||
onClick={() => navigate("/tenant/ai/providers")}
|
||||
className="flex items-center gap-1 text-xs text-slate-500 hover:text-slate-800 transition-colors font-medium select-none cursor-pointer"
|
||||
>
|
||||
<ArrowLeft className="w-3.5 h-3.5" />
|
||||
Back to AI Providers List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-6 max-w-4xl select-none">
|
||||
{/* General Settings Section */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-slate-800 select-none border-b border-slate-100 pb-2">
|
||||
General Settings
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="provider"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Provider"
|
||||
required
|
||||
placeholder="e.g., Mistral7b"
|
||||
error={errors.provider?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="display_name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Display Name"
|
||||
placeholder="e.g., Mistral small 7b"
|
||||
error={errors.display_name?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-slate-50 p-4 rounded-xl">
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-semibold text-slate-800 block">
|
||||
Config Type <span className="text-red-500">*</span>
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 block">
|
||||
Select between Direct and Azure API configurations.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="config_type"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div className="flex items-center gap-6">
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="radio"
|
||||
checked={field.value === "azure"}
|
||||
onChange={() => field.onChange("azure")}
|
||||
className="w-4 h-4 text-[#112868] focus:ring-[#112868] border-slate-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Azure
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer select-none">
|
||||
<input
|
||||
type="radio"
|
||||
checked={field.value === "direct"}
|
||||
onChange={() => field.onChange("direct")}
|
||||
className="w-4 h-4 text-[#112868] focus:ring-[#112868] border-slate-300"
|
||||
/>
|
||||
<span className="text-sm font-medium text-slate-700">
|
||||
Direct
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-4 bg-slate-50 rounded-xl">
|
||||
<div>
|
||||
<span className="text-sm font-semibold text-slate-800 block">
|
||||
Enable Configuration
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 block">
|
||||
Activate this configuration to allow processing completions via this provider.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Controller
|
||||
name="is_active"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<div
|
||||
onClick={() => field.onChange(!field.value)}
|
||||
className={`relative w-11 h-6 flex items-center rounded-full transition-colors cursor-pointer select-none ${
|
||||
field.value ? "bg-[#112868]" : "bg-slate-200"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-[2px] left-[2px] w-5 h-5 bg-white rounded-full transition-transform shadow-sm ${
|
||||
field.value ? "translate-x-5" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Connection Credentials Section */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-slate-800 border-b border-slate-100 pb-2">
|
||||
Connection Credentials
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="api_key"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="API Key"
|
||||
type="password"
|
||||
required
|
||||
placeholder="Enter secret API key..."
|
||||
error={errors.api_key?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="endpoint"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Endpoint URL"
|
||||
placeholder="e.g. https://my-tenant.openai.azure.com/"
|
||||
error={errors.endpoint?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
{selectedConfigType === "azure" && (
|
||||
<>
|
||||
<Controller
|
||||
name="deployment"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Deployment Name"
|
||||
placeholder="e.g., gpt-4-deployment"
|
||||
error={errors.deployment?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="api_version"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="API Version"
|
||||
placeholder="e.g., 2023-05-15"
|
||||
error={errors.api_version?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Models */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm p-6 space-y-4">
|
||||
<h2 className="text-base font-semibold text-slate-800 border-b border-slate-100 pb-2">
|
||||
Models
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
<Controller
|
||||
name="default_model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Default Model"
|
||||
required
|
||||
placeholder="e.g. gpt-4"
|
||||
error={errors.default_model?.message as any}
|
||||
helperText="Fallback model for this provider."
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="custom_models"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormTagInput
|
||||
{...field}
|
||||
label="Custom Models (Optional)"
|
||||
placeholder="Type a model ID and press enter..."
|
||||
error={errors.custom_models?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Controller
|
||||
name="default_embedding_model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormField
|
||||
{...field}
|
||||
label="Default Embedding Model (Optional)"
|
||||
placeholder="e.g. text-embedding-ada-002"
|
||||
error={errors.default_embedding_model?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
<Controller
|
||||
name="custom_embedding_models"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<FormTagInput
|
||||
{...field}
|
||||
label="Custom Embedding Models (Optional)"
|
||||
placeholder="Type a model ID and press enter..."
|
||||
error={errors.custom_embedding_models?.message as any}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantAIProviderCreate;
|
||||
307
src/pages/tenant/TenantAIProviders.tsx
Normal file
307
src/pages/tenant/TenantAIProviders.tsx
Normal file
@ -0,0 +1,307 @@
|
||||
import { useEffect, useMemo, useState, type ReactElement } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Layout } from "@/components/layout/Layout";
|
||||
import {
|
||||
DataTable,
|
||||
type Column,
|
||||
SearchBox,
|
||||
FilterDropdown,
|
||||
ActionDropdown,
|
||||
PrimaryButton,
|
||||
} from "@/components/shared";
|
||||
import { Plus, Play, Loader2, Eye, Trash2 } from "lucide-react";
|
||||
import { aiService } from "@/services/ai-service";
|
||||
import type { TenantAIConfig } from "@/types/ai";
|
||||
import { showToast } from "@/utils/toast";
|
||||
import { formatDate } from "@/utils/format-date";
|
||||
import { ViewAIProviderModal } from "@/components/tenant/ViewAIProviderModal";
|
||||
|
||||
export const TenantAIProviders = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [configs, setConfigs] = useState<TenantAIConfig[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||
|
||||
const [testingProviders, setTestingProviders] = useState<Record<string, boolean>>({});
|
||||
const [selectedConfig, setSelectedConfig] = useState<TenantAIConfig | null>(null);
|
||||
const [isViewModalOpen, setIsViewModalOpen] = useState<boolean>(false);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await aiService.listConfigs();
|
||||
setConfigs(data || []);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error?.message || "Failed to load configs";
|
||||
setError(msg);
|
||||
showToast.error(msg);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const handleTestConnection = async (provider: string) => {
|
||||
setTestingProviders((prev) => ({ ...prev, [provider]: true }));
|
||||
try {
|
||||
const resp = await aiService.testConfig(provider);
|
||||
if (resp && resp.healthy) {
|
||||
showToast.success(
|
||||
`Connection healthy for ${provider}! Latency: ${resp.latency_ms || "N/A"} ms`
|
||||
);
|
||||
} else {
|
||||
showToast.error(`Connection test failed for ${provider}.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error?.message || "Failed to test connection.";
|
||||
showToast.error(msg);
|
||||
} finally {
|
||||
setTestingProviders((prev) => ({ ...prev, [provider]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewConfig = async (provider: string) => {
|
||||
try {
|
||||
const cfg = await aiService.getConfig(provider);
|
||||
setSelectedConfig(cfg);
|
||||
setIsViewModalOpen(true);
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error?.message || "Failed to fetch AI Provider config details.";
|
||||
showToast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteConfig = async (provider: string) => {
|
||||
if (!window.confirm(`Are you sure you want to delete the AI provider configuration for ${provider}?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await aiService.deleteConfig(provider);
|
||||
showToast.success(`${provider} config removed successfully`);
|
||||
void fetchConfigs();
|
||||
} catch (err: any) {
|
||||
const msg = err?.response?.data?.error?.message || "Failed to delete AI Provider configuration.";
|
||||
showToast.error(msg);
|
||||
}
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchQuery("");
|
||||
setStatusFilter("");
|
||||
};
|
||||
|
||||
const filteredConfigs = useMemo(() => {
|
||||
return configs.filter((cfg) => {
|
||||
const searchMatches =
|
||||
!searchQuery.trim() ||
|
||||
(cfg.provider || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(cfg.display_name || "").toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
(cfg.default_model || "").toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const statusMatches =
|
||||
!statusFilter ||
|
||||
(statusFilter === "active" ? cfg.is_active : !cfg.is_active);
|
||||
|
||||
return searchMatches && statusMatches;
|
||||
});
|
||||
}, [configs, searchQuery, statusFilter]);
|
||||
|
||||
const columns: Column<TenantAIConfig>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: "provider",
|
||||
label: "Provider",
|
||||
render: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold text-[#112868] select-none">
|
||||
{row.display_name || row.provider}
|
||||
</p>
|
||||
{row.endpoint && (
|
||||
<p className="text-xs text-[#64748b] mt-0.5 truncate max-w-[280px]">
|
||||
{row.endpoint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "config_type",
|
||||
label: "Config Type",
|
||||
render: (row) => {
|
||||
const type = row.config_type || "direct";
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center px-2.5 py-0.5 rounded-md text-[11px] font-bold uppercase tracking-wider ${
|
||||
type.toLowerCase() === "azure"
|
||||
? "bg-purple-50 text-purple-600 border border-purple-100"
|
||||
: "bg-blue-50 text-blue-600 border border-blue-100"
|
||||
}`}
|
||||
>
|
||||
{type}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "default_model",
|
||||
label: "Default Model",
|
||||
render: (row) => (
|
||||
<span className="text-xs text-slate-800 font-medium select-none">
|
||||
{row.default_model || "—"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "is_active",
|
||||
label: "Status",
|
||||
render: (row) => {
|
||||
const active = row.is_active;
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium w-fit ${
|
||||
active
|
||||
? "text-green-700 bg-green-50 border border-green-100"
|
||||
: "text-gray-500 bg-gray-50 border border-gray-100"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
active ? "bg-green-500" : "bg-gray-400"
|
||||
}`}
|
||||
/>
|
||||
{active ? "Active" : "Disabled"}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "last_verified_at",
|
||||
label: "Last Verified",
|
||||
render: (row) => (
|
||||
<span className="text-xs text-[#64748b]">
|
||||
{row.last_verified_at ? formatDate(row.last_verified_at) : "Never"}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
label: "Actions",
|
||||
align: "right",
|
||||
render: (row) => {
|
||||
const isTesting = testingProviders[row.provider] || false;
|
||||
return (
|
||||
<div className="flex items-center justify-end gap-2 pr-2">
|
||||
<button
|
||||
onClick={() => handleTestConnection(row.provider)}
|
||||
disabled={isTesting}
|
||||
className="flex items-center gap-1 px-3 py-1.5 border border-[#112868]/20 rounded-md text-xs font-medium bg-white text-[#112868] hover:bg-[#112868]/5 transition-colors cursor-pointer disabled:opacity-50 select-none h-8"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin text-[#112868]" />
|
||||
) : (
|
||||
<Play className="w-3.5 h-3.5 fill-[#112868] text-[#112868]" />
|
||||
)}
|
||||
{isTesting ? "Testing..." : "Test Connection"}
|
||||
</button>
|
||||
|
||||
<ActionDropdown
|
||||
actions={[
|
||||
{
|
||||
icon: <Eye className="w-3.5 h-3.5 shrink-0" />,
|
||||
label: "View Config",
|
||||
onClick: () => handleViewConfig(row.provider),
|
||||
},
|
||||
{
|
||||
icon: <Trash2 className="w-3.5 h-3.5 text-red-500 shrink-0" />,
|
||||
label: "Delete Config",
|
||||
onClick: () => handleDeleteConfig(row.provider),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[testingProviders]
|
||||
);
|
||||
|
||||
return (
|
||||
<Layout
|
||||
currentPage="AI Gateway Services"
|
||||
pageHeader={{
|
||||
title: "Tenant AI Providers List",
|
||||
description: "Manage tenant API keys and models for AI integrations.",
|
||||
action: (
|
||||
<PrimaryButton
|
||||
onClick={() => navigate("/tenant/ai/providers/create")}
|
||||
className="h-10 px-4 flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Create AI Provider
|
||||
</PrimaryButton>
|
||||
),
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-5">
|
||||
{/* Subhead Toolbar matching Screenshot filter design */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<SearchBox
|
||||
value={searchQuery}
|
||||
onChange={setSearchQuery}
|
||||
placeholder="Search Here"
|
||||
containerClassName="relative w-full sm:w-[280px]"
|
||||
/>
|
||||
|
||||
<FilterDropdown
|
||||
label="Status"
|
||||
value={statusFilter || null}
|
||||
onChange={(v) => setStatusFilter(typeof v === "string" ? v : "")}
|
||||
placeholder="All"
|
||||
options={[
|
||||
{ value: "active", label: "Active" },
|
||||
{ value: "disabled", label: "Disabled" },
|
||||
]}
|
||||
/>
|
||||
|
||||
{(searchQuery || statusFilter) && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-slate-500 hover:text-red-500 font-medium transition-colors cursor-pointer select-none"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table list */}
|
||||
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-xl shadow-sm overflow-hidden select-none">
|
||||
<DataTable
|
||||
data={filteredConfigs}
|
||||
columns={columns}
|
||||
keyExtractor={(item) => item.id || item.provider}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
emptyMessage="No tenant AI providers configured."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ViewAIProviderModal
|
||||
isOpen={isViewModalOpen}
|
||||
onClose={() => setIsViewModalOpen(false)}
|
||||
config={selectedConfig}
|
||||
/>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantAIProviders;
|
||||
@ -38,7 +38,7 @@ const FileView = lazy(() => import("@/pages/tenant/FileView"));
|
||||
const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
||||
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
|
||||
const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
|
||||
// const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
|
||||
const CompletionHistory = lazy(
|
||||
() => import("@/pages/tenant/CompletionHistory"),
|
||||
);
|
||||
@ -51,6 +51,13 @@ const PromptTestCases = lazy(() => import("@/pages/tenant/PromptTestCases"));
|
||||
const PromptTestCaseCreate = lazy(
|
||||
() => import("@/pages/tenant/PromptTestCaseCreate"),
|
||||
);
|
||||
const TenantAIProviders = lazy(
|
||||
() => import("@/pages/tenant/TenantAIProviders"),
|
||||
);
|
||||
const TenantAIProviderCreate = lazy(
|
||||
() => import("@/pages/tenant/TenantAIProviderCreate"),
|
||||
);
|
||||
const TenantAIDashboard = lazy(() => import("@/pages/tenant/TenantAIDashboard"));
|
||||
|
||||
// Loading fallback component
|
||||
const RouteLoader = (): ReactElement => (
|
||||
@ -197,9 +204,17 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
path: "/tenant/ai/completions/:completionId",
|
||||
element: <LazyRoute component={CompletionDetail} />,
|
||||
},
|
||||
// {
|
||||
// path: "/tenant/ai/config",
|
||||
// element: <LazyRoute component={AIGateway} />,
|
||||
// },
|
||||
{
|
||||
path: "/tenant/ai/config",
|
||||
element: <LazyRoute component={AIGateway} />,
|
||||
path: "/tenant/ai/providers",
|
||||
element: <LazyRoute component={TenantAIProviders} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/ai/providers/create",
|
||||
element: <LazyRoute component={TenantAIProviderCreate} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/ai/prompts",
|
||||
@ -226,7 +241,11 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
||||
element: <LazyRoute component={PromptTestCases} />,
|
||||
},
|
||||
{
|
||||
path: "/tenant/ai/knowledge",
|
||||
element: <LazyRoute component={AIGateway} />,
|
||||
path: "/tenant/ai/dashboard",
|
||||
element: <LazyRoute component={TenantAIDashboard} />,
|
||||
},
|
||||
// {
|
||||
// path: "/tenant/ai/knowledge",
|
||||
// element: <LazyRoute component={AIGateway} />,
|
||||
// },
|
||||
];
|
||||
|
||||
@ -114,6 +114,11 @@ class AIService {
|
||||
return unwrap<TenantAIConfig[]>(response);
|
||||
}
|
||||
|
||||
async getConfig(provider: string): Promise<TenantAIConfig> {
|
||||
const response = await apiClient.get(`/ai/config/${encodeURIComponent(provider)}`);
|
||||
return unwrap<TenantAIConfig>(response);
|
||||
}
|
||||
|
||||
async testConfig(provider: string): Promise<AIHealthResponse> {
|
||||
const response = await apiClient.post(`/ai/config/${encodeURIComponent(provider)}/test`, {});
|
||||
return unwrap<AIHealthResponse>(response);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user