feat: add AI services menu to tenant admin platform and enhance DataTable with expandable rows and filters

This commit is contained in:
Yashwin 2026-04-20 15:09:54 +05:30
parent edb631df36
commit 93dd820fe2
18 changed files with 2891 additions and 104 deletions

View File

@ -16,6 +16,7 @@ import {
ChevronRight,
Bell,
Paperclip,
Bot,
} from "lucide-react";
import { cn } from "@/lib/utils";
@ -116,6 +117,34 @@ const tenantAdminPlatformMenu: MenuItem[] = [
];
const tenantAdminPlatformServiceMenu: MenuItem[] = [
{
icon: Bot,
label: "AI Services",
isGroup: true,
children: [
{
label: "Completion History",
path: "/tenant/ai/completions",
requiredPermission: { resource: "ai" },
},
{
label: "Tenant Config",
path: "/tenant/ai/config",
requiredPermission: { resource: "ai" },
},
{
label: "Prompts",
path: "/tenant/ai/prompts",
requiredPermission: { resource: "ai" },
},
{
label: "Knowledge (RAG)",
path: "/tenant/ai/knowledge",
requiredPermission: { resource: "ai" },
},
],
requiredPermission: { resource: "ai" },
},
{
icon: Paperclip,
label: "File Attachments",

View File

@ -1,4 +1,6 @@
import type { ReactElement, ReactNode } from 'react';
import { Fragment } from 'react';
import { ChevronDown } from 'lucide-react';
export interface Column<T> {
key: string;
@ -16,6 +18,13 @@ interface DataTableProps<T> {
emptyMessage?: string;
isLoading?: boolean;
error?: string | null;
expandableRows?: boolean;
isRowExpanded?: (item: T) => boolean;
onRowExpandToggle?: (item: T) => void;
renderExpandedRow?: (item: T) => ReactNode;
onRowClick?: (item: T) => void;
showExpandColumn?: boolean;
expandedColSpan?: number;
}
export const DataTable = <T,>({
@ -26,7 +35,17 @@ export const DataTable = <T,>({
emptyMessage = 'No data found',
isLoading = false,
error = null,
expandableRows = false,
isRowExpanded,
onRowExpandToggle,
renderExpandedRow,
onRowClick,
showExpandColumn = true,
expandedColSpan,
}: DataTableProps<T>): ReactElement => {
const canExpand = expandableRows && !!onRowExpandToggle && !!isRowExpanded && !!renderExpandedRow;
const desktopColSpan = expandedColSpan || columns.length + (canExpand && showExpandColumn ? 1 : 0);
// Loading State
if (isLoading) {
return (
@ -55,6 +74,9 @@ export const DataTable = <T,>({
<table className="w-full">
<thead>
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
{canExpand && showExpandColumn && (
<th className="w-10 px-2 py-2 md:py-1 lg:py-2.5 xl:py-3 text-left" aria-label="Expand" />
)}
{columns.map((column) => {
const alignClass =
column.align === 'right'
@ -75,7 +97,7 @@ export const DataTable = <T,>({
</thead>
<tbody>
<tr>
<td colSpan={columns.length} className="px-3 md:px-2 lg:px-4 xl:px-5 py-6 md:py-3 lg:py-7 xl:py-8 text-center text-xs md:text-xs lg:text-sm text-[#6b7280]">
<td colSpan={desktopColSpan} className="px-3 md:px-2 lg:px-4 xl:px-5 py-6 md:py-3 lg:py-7 xl:py-8 text-center text-xs md:text-xs lg:text-sm text-[#6b7280]">
{emptyMessage}
</td>
</tr>
@ -99,6 +121,9 @@ export const DataTable = <T,>({
<table className="w-full">
<thead>
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
{canExpand && showExpandColumn && (
<th className="w-10 px-2 py-2 md:py-1 lg:py-2.5 xl:py-3 text-left" aria-label="Expand" />
)}
{columns.map((column) => {
const alignClass =
column.align === 'right'
@ -118,26 +143,54 @@ export const DataTable = <T,>({
</tr>
</thead>
<tbody>
{data.map((item) => (
<tr
key={keyExtractor(item)}
className="border-b border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
>
{columns.map((column) => {
const alignClass =
column.align === 'right'
? 'text-right'
: column.align === 'center'
? 'text-center'
: 'text-left';
return (
<td key={column.key} className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4 ${alignClass} text-xs md:text-[10px] lg:text-sm`}>
{column.render ? column.render(item) : String((item as any)[column.key])}
</td>
);
})}
</tr>
))}
{data.map((item) => {
const rowId = keyExtractor(item);
const expanded = canExpand ? !!isRowExpanded(item) : false;
return (
<Fragment key={rowId}>
<tr
className="border-b border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
onClick={onRowClick ? () => onRowClick(item) : undefined}
>
{canExpand && showExpandColumn && (
<td className="px-2 py-2.5 md:py-1.5 lg:py-3 xl:py-4 align-middle">
<button
type="button"
className="p-1 rounded hover:bg-gray-200/80 text-[#64748b]"
aria-expanded={expanded}
onClick={(e) => {
e.stopPropagation();
onRowExpandToggle(item);
}}
>
<ChevronDown className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
</td>
)}
{columns.map((column) => {
const alignClass =
column.align === 'right'
? 'text-right'
: column.align === 'center'
? 'text-center'
: 'text-left';
return (
<td key={column.key} className={`px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4 ${alignClass} text-xs md:text-[10px] lg:text-sm`}>
{column.render ? column.render(item) : String((item as any)[column.key])}
</td>
);
})}
</tr>
{canExpand && expanded && (
<tr className="border-b border-[rgba(0,0,0,0.08)] bg-white">
<td colSpan={desktopColSpan} className="px-3 md:px-2 lg:px-4 xl:px-5 py-2.5 md:py-1.5 lg:py-3 xl:py-4">
{renderExpandedRow(item)}
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
</div>
@ -147,20 +200,40 @@ export const DataTable = <T,>({
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
{mobileCardRenderer
? data.map((item) => <div key={keyExtractor(item)}>{mobileCardRenderer(item)}</div>)
: data.map((item) => (
<div key={keyExtractor(item)} className="p-3 sm:p-4">
{columns.map((column) => (
<div key={column.key} className="mb-2.5 sm:mb-3 last:mb-0">
<span className="text-[10px] sm:text-xs text-[#9aa6b2] mb-0.5 sm:mb-1 block">
{column.mobileLabel || column.label}:
</span>
<div className="text-xs sm:text-sm text-[#0f1724]">
{column.render ? column.render(item) : String((item as any)[column.key])}
: data.map((item) => {
const expanded = canExpand ? !!isRowExpanded(item) : false;
return (
<div key={keyExtractor(item)} className="p-3 sm:p-4">
{canExpand && (
<div className="mb-2">
<button
type="button"
className="p-1 rounded hover:bg-gray-100 text-[#64748b]"
aria-expanded={expanded}
onClick={() => onRowExpandToggle(item)}
>
<ChevronDown className={`w-4 h-4 transition-transform ${expanded ? 'rotate-180' : ''}`} />
</button>
</div>
</div>
))}
</div>
))}
)}
{columns.map((column) => (
<div key={column.key} className="mb-2.5 sm:mb-3 last:mb-0">
<span className="text-[10px] sm:text-xs text-[#9aa6b2] mb-0.5 sm:mb-1 block">
{column.mobileLabel || column.label}:
</span>
<div className="text-xs sm:text-sm text-[#0f1724]">
{column.render ? column.render(item) : String((item as any)[column.key])}
</div>
</div>
))}
{canExpand && expanded && renderExpandedRow && (
<div className="mt-3">
{renderExpandedRow(item)}
</div>
)}
</div>
);
})}
</div>
</>
);

View File

@ -113,7 +113,7 @@ const AuditLogResourceTypes = (): ReactElement => {
const fetchModules = async () => {
try {
const response = await moduleService.getAll(1, 100);
const response = await moduleService.getDropdown();
if (response.success) {
setModules(response.data);
}
@ -325,6 +325,7 @@ const AuditLogResourceTypes = (): ReactElement => {
setCurrentPage(1);
}}
placeholder="All Modules"
isSearchable
/>
{/* Sort Filter */}

View File

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import type { ReactElement } from 'react';
import { Layout } from '@/components/layout/Layout';
import {
@ -9,11 +9,13 @@ import {
StatusBadge,
type Column,
} from '@/components/shared';
import { Download, ArrowUpDown, Search } from 'lucide-react';
import { Download, ArrowUpDown, Search, ChevronDown, SlidersHorizontal } from 'lucide-react';
import { auditLogService } from '@/services/audit-log-service';
import { tenantService } from '@/services/tenant-service';
import type { AuditLog } from '@/types/audit-log';
import type { Tenant } from '@/types/tenant';
import { cn } from '@/lib/utils';
import { useAppTheme } from '@/hooks/useAppTheme';
// Helper function to format date
const formatDate = (dateString: string): string => {
@ -58,8 +60,10 @@ const getStatusColor = (status: number | null): string => {
};
const AuditLogs = (): ReactElement => {
const { primaryColor } = useAppTheme();
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [modules, setModules] = useState<Array<{ id: string; name: string }>>([]);
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
@ -86,11 +90,18 @@ const AuditLogs = (): ReactElement => {
const [methodFilter, setMethodFilter] = useState<string | null>(null);
const [actionFilter, setActionFilter] = useState<string | null>(null);
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [orderBy, setOrderBy] = useState<string[] | null>(null);
const [search, setSearch] = useState<string>('');
const [debouncedSearch, setDebouncedSearch] = useState<string>('');
const [showMoreFilters, setShowMoreFilters] = useState<boolean>(false);
const hasExtraFilters = useMemo(
() => Boolean(moduleFilter || methodFilter || startDate || endDate || orderBy),
[moduleFilter, methodFilter, startDate, endDate, orderBy]
);
// View modal
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
@ -124,10 +135,28 @@ const AuditLogs = (): ReactElement => {
}
};
const fetchModules = async () => {
try {
const response = await auditLogService.getModulesDropdown();
if (response.success) {
setModules(response.data || []);
}
} catch (err) {
console.error('Failed to load modules:', err);
}
};
fetchTenants();
fetchResourceTypes();
fetchModules();
}, []);
useEffect(() => {
if (hasExtraFilters) {
setShowMoreFilters(true);
}
}, [hasExtraFilters]);
const fetchAuditLogs = async (): Promise<void> => {
try {
setIsLoading(true);
@ -140,6 +169,7 @@ const AuditLogs = (): ReactElement => {
method: methodFilter,
action: actionFilter,
resource_type: resourceTypeFilter,
module_id: moduleFilter,
startDate: startDate || null,
endDate: endDate || null,
search: debouncedSearch || null,
@ -171,7 +201,7 @@ const AuditLogs = (): ReactElement => {
// Fetch audit logs on mount and when pagination/filters change
useEffect(() => {
fetchAuditLogs();
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, debouncedSearch]);
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, moduleFilter, startDate, endDate, orderBy, debouncedSearch]);
// Handle Export
const handleExport = async (): Promise<void> => {
@ -414,22 +444,30 @@ const AuditLogs = (): ReactElement => {
isSearchable
/>
{/* Method Filter */}
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Methods"
/>
<button
type="button"
onClick={() => setShowMoreFilters((open) => !open)}
className={cn(
'inline-flex items-center gap-1.5 h-10 px-3 rounded-md text-sm font-medium border bg-white transition-colors',
showMoreFilters || hasExtraFilters
? 'border-[rgba(8,76,200,0.35)] text-[#0f1724]'
: 'border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30'
)}
style={
showMoreFilters || hasExtraFilters
? { borderColor: `${primaryColor}55`, color: primaryColor }
: undefined
}
>
<SlidersHorizontal className="w-3.5 h-3.5 shrink-0" />
More filters
<ChevronDown
className={cn(
'w-3.5 h-3.5 shrink-0 opacity-70 transition-transform',
showMoreFilters && 'rotate-180'
)}
/>
</button>
</div>
{/* Actions */}
@ -445,65 +483,105 @@ const AuditLogs = (): ReactElement => {
</div>
</div>
<div className="flex flex-wrap items-center gap-3 md:gap-6 pt-1 border-t border-gray-50 mt-1 pt-3">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">Start Date:</span>
<input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">End Date:</span>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
{showMoreFilters && (
<div className="flex flex-col gap-3 border-t border-gray-50 mt-1 pt-3">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Module"
options={modules.map((m) => ({ value: m.id, label: m.name }))}
value={moduleFilter}
onChange={(value) => {
setModuleFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Modules"
isSearchable
/>
<FilterDropdown
label="Sort"
options={[
{ value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Newest"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
<FilterDropdown
label="Method"
options={[
{ value: 'GET', label: 'GET' },
{ value: 'POST', label: 'POST' },
{ value: 'PUT', label: 'PUT' },
{ value: 'DELETE', label: 'DELETE' },
]}
value={methodFilter}
onChange={(value) => {
setMethodFilter(value as string | null);
setCurrentPage(1);
}}
placeholder="All Methods"
/>
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || search) && (
<button
<FilterDropdown
label="Sort"
options={[
{ value: ['created_at', 'desc'], label: 'Newest First' },
{ value: ['created_at', 'asc'], label: 'Oldest First' },
]}
value={orderBy}
onChange={(value) => {
setOrderBy(value as string[] | null);
setCurrentPage(1);
}}
placeholder="Newest"
showIcon
icon={<ArrowUpDown className="w-3.5 h-3.5 text-[#475569]" />}
/>
</div>
<div className="flex flex-wrap items-center gap-4 md:gap-6">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">Start Date:</span>
<input
type="date"
value={startDate}
onChange={(e) => {
setStartDate(e.target.value);
setCurrentPage(1);
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">End Date:</span>
<input
type="date"
value={endDate}
onChange={(e) => {
setEndDate(e.target.value);
setCurrentPage(1);
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
</div>
</div>
)}
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || moduleFilter || methodFilter || search || orderBy) && (
<div className="flex justify-end pt-1">
<button
onClick={() => {
setStartDate('');
setEndDate('');
setTenantFilter(null);
setActionFilter(null);
setResourceTypeFilter(null);
setModuleFilter(null);
setMethodFilter(null);
setOrderBy(null);
setSearch('');
setShowMoreFilters(false);
setCurrentPage(1);
}}
className="text-xs text-[#ef4444] hover:underline ml-auto"
className="text-xs text-[#ef4444] hover:underline"
>
Reset All Filters
</button>
)}
</div>
</div>
)}
</div>
{/* Table */}

View File

@ -10,6 +10,7 @@ import {
FormField,
FormTextArea,
FormSelect,
FilterDropdown,
Pagination,
type Column,
} from '@/components/shared';
@ -117,6 +118,7 @@ const NotificationMaster = (): ReactElement => {
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [search, setSearch] = useState('');
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
// Category Modal
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
@ -170,7 +172,8 @@ const NotificationMaster = (): ReactElement => {
const res = await notificationService.getCategories({
limit,
offset: (currentPage - 1) * limit,
search
search,
module_id: moduleFilter || undefined
});
if (res.success) {
setCategories(res.data);
@ -186,7 +189,7 @@ const NotificationMaster = (): ReactElement => {
const fetchModules = async () => {
try {
const res = await moduleService.getAll(1, 100);
const res = await moduleService.getDropdown();
if (res.success) setModules(res.data);
} catch (err) {
console.error('Failed to fetch modules', err);
@ -196,7 +199,7 @@ const NotificationMaster = (): ReactElement => {
useEffect(() => {
fetchCategories();
fetchModules();
}, [currentPage, limit, search]);
}, [currentPage, limit, search, moduleFilter]);
const fetchCodes = async (category: any, page: number = 1) => {
try {
@ -344,6 +347,18 @@ const NotificationMaster = (): ReactElement => {
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
/>
</div>
<FilterDropdown
label="Module"
value={moduleFilter}
onChange={(val) => {
setModuleFilter(val as string | null);
setCurrentPage(1);
}}
options={modules.map((m) => ({ value: m.id, label: m.name }))}
placeholder="All Modules"
isSearchable
/>
</div>
<PrimaryButton onClick={() => {
setEditingCategory(null);

View File

@ -154,7 +154,7 @@ const NotificationTemplateMaster = (): ReactElement => {
module_id: selectedModule || undefined,
}),
notificationService.getCategories({ limit: 100 }),
moduleService.getAll(1, 100),
moduleService.getDropdown(),
]);
if (tRes.success) {

View File

@ -301,7 +301,7 @@ const TenantDetails = (): ReactElement => {
</div>
</div>
<button
onClick={() => navigate("/tenants")}
onClick={() => navigate(`/tenants/${id}/edit`)}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-[#112868] bg-white border border-[rgba(0,0,0,0.08)] rounded-md hover:bg-gray-50 transition-colors"
>
<Edit className="w-4 h-4" />

View File

@ -0,0 +1,952 @@
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { Link, useLocation } from "react-router-dom";
import { Brain, Layers, PlayCircle, Settings2 } from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
type Column,
FormField,
FormSelect,
FormTextArea,
PrimaryButton,
SecondaryButton,
StatusBadge,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type {
AICompletion,
AICostSummary,
AIProviderInfo,
AIPrompt,
KnowledgeCollection,
TenantAIConfig,
} from "@/types/ai";
import { showToast } from "@/utils/toast";
import { cn } from "@/lib/utils";
type TabKey = "gateway" | "config" | "prompts" | "knowledge";
const getTabFromPath = (pathname: string): TabKey => {
if (pathname.endsWith("/ai/config")) return "config";
if (pathname.endsWith("/ai/prompts")) return "prompts";
if (pathname.endsWith("/ai/knowledge")) return "knowledge";
return "gateway";
};
const tabPath: Record<TabKey, string> = {
gateway: "/tenant/ai",
config: "/tenant/ai/config",
prompts: "/tenant/ai/prompts",
knowledge: "/tenant/ai/knowledge",
};
const tabMeta: Record<TabKey, { label: string; icon: any; subtitle: string }> = {
gateway: {
label: "Completion Playground",
icon: PlayCircle,
subtitle: "Run completions, monitor health, and review costs.",
},
config: {
label: "Tenant AI Provider Management",
icon: Settings2,
subtitle: "Configure provider keys/endpoints and test connectivity.",
},
prompts: {
label: "Prompt Management & Testing",
icon: Layers,
subtitle: "Create prompts, activate them, and test outputs safely.",
},
knowledge: {
label: "RAG Knowledge Workspace",
icon: Brain,
subtitle: "Create collections, ingest documents, and run context search.",
},
};
const Card = ({ title, description, children }: { title: string; description?: string; children: React.ReactNode }) => (
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-5">
<div className="mb-4">
<h3 className="text-sm md:text-base font-semibold text-[#0f1724]">{title}</h3>
{description && <p className="text-xs md:text-sm text-[#6b7280] mt-1">{description}</p>}
</div>
{children}
</section>
);
const StatTile = ({ label, value, hint }: { label: string; value: string; hint?: string }) => (
<div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-lg p-3">
<p className="text-[11px] uppercase tracking-wide text-[#6b7280]">{label}</p>
<p className="text-xl font-semibold text-[#0f1724] mt-1">{value}</p>
{hint && <p className="text-xs text-[#94a3b8] mt-0.5">{hint}</p>}
</div>
);
const AIGateway = (): ReactElement => {
const location = useLocation();
const activeTab = getTabFromPath(location.pathname);
const ActiveIcon = tabMeta[activeTab].icon;
const [isLoading, setIsLoading] = useState<boolean>(false);
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
const [completions, setCompletions] = useState<AICompletion[]>([]);
const [gatewayHealthy, setGatewayHealthy] = useState<boolean>(true);
const [configs, setConfigs] = useState<TenantAIConfig[]>([]);
const [prompts, setPrompts] = useState<AIPrompt[]>([]);
const [collections, setCollections] = useState<KnowledgeCollection[]>([]);
const [costSummary, setCostSummary] = useState<AICostSummary | null>(null);
const [completionForm, setCompletionForm] = useState({
provider: "",
model: "",
message: "Define FDA 21 CFR Part 11 in one paragraph.",
temperature: "0.3",
max_tokens: "250",
});
const [playgroundResult, setPlaygroundResult] = useState<string>("");
const [playgroundMeta, setPlaygroundMeta] = useState<{ provider?: string; model?: string; latency?: number }>({});
const [creatingCompletion, setCreatingCompletion] = useState<boolean>(false);
const [costFilter, setCostFilter] = useState<{ group_by: "day" | "week" | "month" }>({ group_by: "day" });
const [configForm, setConfigForm] = useState({
provider: "",
config_type: "direct",
api_key: "",
endpoint: "",
default_model: "",
custom_models: "",
});
const [savingConfig, setSavingConfig] = useState<boolean>(false);
const [promptForm, setPromptForm] = useState({
name: "",
use_case: "quality_review",
user_template: "Analyze this topic: {{topic}}",
system_message: "You are a quality compliance assistant.",
provider: "",
model: "",
temperature: "0.2",
max_tokens: "800",
});
const [creatingPrompt, setCreatingPrompt] = useState<boolean>(false);
const [executePromptId, setExecutePromptId] = useState<string>("");
const [executeVariables, setExecuteVariables] = useState<string>('{"topic":"CAPA risk assessment"}');
const [executeResult, setExecuteResult] = useState<string>("");
const [executeMeta, setExecuteMeta] = useState<{ provider?: string; model?: string }>({});
const [collectionForm, setCollectionForm] = useState({ name: "", description: "" });
const [creatingCollection, setCreatingCollection] = useState<boolean>(false);
const [uploadCollectionId, setUploadCollectionId] = useState<string>("");
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [uploadingDoc, setUploadingDoc] = useState<boolean>(false);
const [searchQuery, setSearchQuery] = useState<string>("Summarize the regulatory controls from uploaded SOP.");
const [searchCollectionId, setSearchCollectionId] = useState<string>("");
const [searchResult, setSearchResult] = useState<string>("");
const providerOptions = useMemo(
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
[providers],
);
const modelOptions = useMemo(
() =>
models.map((m) => ({
value: m.id,
label: `${m.id} (${m.provider})${m.isDefault ? " • default" : ""}`,
})),
[models],
);
const collectionOptions = useMemo(
() => collections.map((c) => ({ value: c.id, label: c.name })),
[collections],
);
const loadGatewayData = async (): Promise<void> => {
setIsLoading(true);
try {
const [providerData, modelData, healthData, completionData, costs] = await Promise.all([
aiService.getProviders(),
aiService.getModels(),
aiService.getGatewayHealth(),
aiService.listCompletions({ page: 1, limit: 10 }),
aiService.getCostSummary(costFilter),
]);
setProviders(providerData);
setModels(modelData);
setGatewayHealthy(Boolean(healthData?.healthy));
setCompletions(completionData.data || []);
setCostSummary(costs);
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to load AI gateway data");
} finally {
setIsLoading(false);
}
};
const loadConfigData = async (): Promise<void> => {
setIsLoading(true);
try {
const [providerData, configData] = await Promise.all([aiService.getProviders(), aiService.listConfigs()]);
setProviders(providerData);
setConfigs(configData);
setConfigForm((prev) => ({ ...prev, provider: prev.provider || providerData[0]?.name || "" }));
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to load config data");
} finally {
setIsLoading(false);
}
};
const loadPromptData = async (): Promise<void> => {
setIsLoading(true);
try {
const [providerData, modelData, promptData] = await Promise.all([
aiService.getProviders(),
aiService.getModels(),
aiService.listPrompts({ page: 1, limit: 20 }),
]);
setProviders(providerData);
setModels(modelData);
setPrompts(promptData.data || []);
setPromptForm((prev) => ({ ...prev, provider: prev.provider || providerData[0]?.name || "" }));
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to load prompts");
} finally {
setIsLoading(false);
}
};
const loadKnowledgeData = async (): Promise<void> => {
setIsLoading(true);
try {
const [providerData, collectionData] = await Promise.all([aiService.getProviders(), aiService.listCollections({ page: 1, limit: 20 })]);
setProviders(providerData);
setCollections(collectionData.data || []);
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to load knowledge data");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (activeTab === "gateway") void loadGatewayData();
if (activeTab === "config") void loadConfigData();
if (activeTab === "prompts") void loadPromptData();
if (activeTab === "knowledge") void loadKnowledgeData();
}, [activeTab, costFilter.group_by]);
const handleRunPlayground = async (): Promise<void> => {
setCreatingCompletion(true);
try {
const result = await aiService.playground({
messages: [{ role: "user", content: completionForm.message }],
provider: completionForm.provider || undefined,
model: completionForm.model || undefined,
temperature: Number(completionForm.temperature),
max_tokens: Number(completionForm.max_tokens),
});
setPlaygroundResult(result.content || result.response || "");
setPlaygroundMeta({ provider: result.provider, model: result.model, latency: result.latency_ms });
showToast.success(`Response from ${result.provider || "provider"}`);
await loadGatewayData();
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to run completion");
} finally {
setCreatingCompletion(false);
}
};
const handleSaveConfig = async (): Promise<void> => {
setSavingConfig(true);
try {
await aiService.upsertConfig({
provider: configForm.provider,
config_type: configForm.config_type as "direct" | "azure",
api_key: configForm.api_key,
endpoint: configForm.endpoint || undefined,
default_model: configForm.default_model || undefined,
custom_models: configForm.custom_models
? configForm.custom_models.split(",").map((m) => m.trim()).filter(Boolean)
: undefined,
});
showToast.success("Provider configuration saved");
setConfigForm((prev) => ({ ...prev, api_key: "" }));
await loadConfigData();
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to save config");
} finally {
setSavingConfig(false);
}
};
const handleCreatePrompt = async (): Promise<void> => {
setCreatingPrompt(true);
try {
await aiService.createPrompt({
name: promptForm.name,
use_case: promptForm.use_case,
user_template: promptForm.user_template,
system_message: promptForm.system_message || undefined,
provider: promptForm.provider || undefined,
model: promptForm.model || undefined,
temperature: Number(promptForm.temperature),
max_tokens: Number(promptForm.max_tokens),
variables: [{ name: "topic", type: "string", required: true }],
});
showToast.success("Prompt created");
setPromptForm((prev) => ({ ...prev, name: "" }));
await loadPromptData();
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to create prompt");
} finally {
setCreatingPrompt(false);
}
};
const handleExecutePrompt = async (): Promise<void> => {
if (!executePromptId) {
showToast.error("Select a prompt to test");
return;
}
try {
const variables = JSON.parse(executeVariables);
const result = await aiService.testPrompt(executePromptId, { variables });
setExecuteResult(result.content || result.response || "");
setExecuteMeta({ provider: result.provider, model: result.model });
showToast.success("Prompt tested successfully");
} catch (error: any) {
showToast.error(error?.message || error?.response?.data?.error?.message || "Failed to execute prompt");
}
};
const handleCreateCollection = async (): Promise<void> => {
setCreatingCollection(true);
try {
await aiService.createCollection({
name: collectionForm.name,
description: collectionForm.description || undefined,
});
setCollectionForm({ name: "", description: "" });
showToast.success("Collection created");
await loadKnowledgeData();
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to create collection");
} finally {
setCreatingCollection(false);
}
};
const handleUploadDoc = async (): Promise<void> => {
if (!uploadCollectionId || !uploadFile) {
showToast.error("Select collection and file");
return;
}
setUploadingDoc(true);
try {
await aiService.uploadKnowledgeDocument({ collectionId: uploadCollectionId, file: uploadFile });
showToast.success("Document uploaded and queued for ingestion");
setUploadFile(null);
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "Failed to upload document");
} finally {
setUploadingDoc(false);
}
};
const handleSearchRag = async (): Promise<void> => {
try {
const result = await aiService.searchKnowledgeWithContext({
query: searchQuery,
collectionId: searchCollectionId || undefined,
topK: 5,
minScore: 0.7,
});
setSearchResult(result.context || JSON.stringify(result.matches || [], null, 2));
} catch (error: any) {
showToast.error(error?.response?.data?.error?.message || "RAG search failed");
}
};
const providerColumns: Column<AIProviderInfo>[] = [
{ key: "name", label: "Provider", render: (row) => row.displayName || row.name },
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={row.isEnabled ? "success" : "failure"}>
{row.isEnabled ? "Enabled" : "Disabled"}
</StatusBadge>
),
},
{ key: "defaultModel", label: "Default Model", render: (row) => row.defaultModel || "-" },
];
const completionColumns: Column<AICompletion>[] = [
{ key: "provider", label: "Provider", render: (row) => row.provider || "-" },
{ key: "model", label: "Model", render: (row) => row.model || "-" },
{ key: "tokens", label: "Tokens", align: "right", render: (row) => String(row.usage?.total_tokens ?? 0) },
{
key: "fallback",
label: "Fallback",
render: (row) => (
<StatusBadge variant={row.fallbackUsed ? "process" : "success"}>
{row.fallbackUsed ? "Used" : "No"}
</StatusBadge>
),
},
{ key: "latency", label: "Latency", align: "right", render: (row) => `${row.latency_ms ?? 0} ms` },
];
const providerCostColumns: Column<{ provider: string; completions: number; tokens: number; cost: number }>[] = [
{ key: "provider", label: "Provider" },
{ key: "completions", label: "Completions", align: "right" },
{ key: "tokens", label: "Tokens", align: "right" },
{ key: "cost", label: "Cost (USD)", align: "right", render: (row) => `$${(row.cost || 0).toFixed(4)}` },
];
const configColumns: Column<TenantAIConfig>[] = [
{ key: "provider", label: "Provider" },
{ key: "config_type", label: "Config Type" },
{ key: "default_model", label: "Default Model", render: (row) => row.default_model || "-" },
{
key: "state",
label: "State",
render: (row) => (
<StatusBadge variant={row.is_active ? "success" : "failure"}>
{row.is_active ? "Active" : "Inactive"}
</StatusBadge>
),
},
{
key: "actions",
label: "Actions",
align: "right",
render: (row) => (
<div className="flex justify-end gap-3">
<button
className="text-xs font-medium text-[#112868] cursor-pointer"
onClick={async () => {
const health = await aiService.testConfig(row.provider);
showToast[health.healthy ? "success" : "error"](
health.healthy ? `${row.provider} healthy` : `${row.provider} unhealthy`,
);
await loadConfigData();
}}
>
Test
</button>
<button
className="text-xs font-medium text-[#ef4444] cursor-pointer"
onClick={async () => {
await aiService.deleteConfig(row.provider);
showToast.success("Config removed");
await loadConfigData();
}}
>
Delete
</button>
</div>
),
},
];
const promptColumns: Column<AIPrompt>[] = [
{ key: "name", label: "Prompt Name" },
{ key: "use_case", label: "Use Case" },
{ key: "provider", label: "Provider", render: (row) => row.provider || "-" },
{ key: "model", label: "Model", render: (row) => row.model || "-" },
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={row.status === "active" ? "success" : "process"}>
{row.status || "draft"}
</StatusBadge>
),
},
{
key: "activate",
label: "Action",
align: "right",
render: (row) => (
<button
className="text-xs font-medium text-[#112868] cursor-pointer"
onClick={async () => {
await aiService.updatePromptStatus(row.id, "active");
showToast.success("Prompt activated");
await loadPromptData();
}}
>
Activate
</button>
),
},
];
const collectionColumns: Column<KnowledgeCollection>[] = [
{ key: "name", label: "Collection" },
{ key: "description", label: "Description", render: (row) => row.description || "-" },
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={row.status === "active" ? "success" : "process"}>
{row.status || "active"}
</StatusBadge>
),
},
];
return (
<Layout
currentPage="AI Services"
pageHeader={{
title: "AI Services Workspace",
description: "Completion playground, provider management, prompt lifecycle, cost dashboard, and RAG operations.",
}}
>
<div className="space-y-5">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-3">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-md bg-[#112868] text-white flex items-center justify-center">
<ActiveIcon className="w-4 h-4" />
</div>
<div>
<p className="text-sm font-semibold text-[#0f1724]">{tabMeta[activeTab].label}</p>
<p className="text-xs text-[#6b7280]">{tabMeta[activeTab].subtitle}</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{(Object.keys(tabPath) as TabKey[]).map((tab) => {
const Icon = tabMeta[tab].icon;
return (
<Link
key={tab}
to={tabPath[tab]}
className={cn(
"px-3 py-2 rounded-md text-xs font-medium flex items-center gap-1.5 transition-colors",
activeTab === tab ? "bg-[#112868] text-white" : "bg-[#f5f7fa] text-[#0f1724] hover:bg-gray-100",
)}
>
<Icon className="w-3.5 h-3.5" />
{tabMeta[tab].label}
</Link>
);
})}
</div>
</div>
</div>
{activeTab === "gateway" && (
<>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3">
<StatTile label="Gateway Health" value={gatewayHealthy ? "Healthy" : "Degraded"} />
<StatTile label="Providers" value={String(providers.length)} />
<StatTile label="Completions" value={String(costSummary?.summary.total_completions || 0)} />
<StatTile label="AI Cost" value={`$${(costSummary?.summary.total_cost || 0).toFixed(4)}`} hint="Current filtered period" />
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<Card
title="Completion Playground"
description="Send quick prompts against selected provider/model before binding to prompt templates."
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormSelect
label="Provider"
value={completionForm.provider}
onValueChange={(value) => setCompletionForm((prev) => ({ ...prev, provider: value }))}
options={providerOptions}
placeholder="Auto"
/>
<FormSelect
label="Model"
value={completionForm.model}
onValueChange={(value) => setCompletionForm((prev) => ({ ...prev, model: value }))}
options={modelOptions}
placeholder="Provider default"
/>
</div>
<FormTextArea
label="Prompt"
value={completionForm.message}
onChange={(e) => setCompletionForm((prev) => ({ ...prev, message: e.target.value }))}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormField
label="Temperature"
type="number"
value={completionForm.temperature}
onChange={(e) => setCompletionForm((prev) => ({ ...prev, temperature: e.target.value }))}
/>
<FormField
label="Max Tokens"
type="number"
value={completionForm.max_tokens}
onChange={(e) => setCompletionForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
/>
</div>
<div className="flex gap-2">
<PrimaryButton onClick={handleRunPlayground} disabled={creatingCompletion}>
{creatingCompletion ? "Running..." : "Run Playground"}
</PrimaryButton>
<SecondaryButton onClick={() => void loadGatewayData()}>Refresh Data</SecondaryButton>
</div>
</Card>
<div className="xl:col-span-2 space-y-4">
<Card
title="Playground Result"
description="Latest raw model response with metadata for quick verification."
>
<div className="flex flex-wrap items-center gap-2 mb-3">
<StatusBadge variant="process">{playgroundMeta.provider || "Provider: -"}</StatusBadge>
<StatusBadge variant="process">{playgroundMeta.model || "Model: -"}</StatusBadge>
<StatusBadge variant="process">
Latency: {playgroundMeta.latency ? `${playgroundMeta.latency} ms` : "-"}
</StatusBadge>
</div>
<div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-md p-3 min-h-[180px]">
<p className="text-sm text-[#0f1724] whitespace-pre-wrap">{playgroundResult || "No output yet."}</p>
</div>
</Card>
<Card
title="AI Usage Cost Dashboard"
description="Monitor completion volumes, token usage, and provider-level spend."
>
<div className="grid grid-cols-1 md:grid-cols-4 gap-3 mb-3">
<FormSelect
label="Group By"
value={costFilter.group_by}
onValueChange={(value) => setCostFilter({ group_by: value as "day" | "week" | "month" })}
options={[
{ value: "day", label: "Daily" },
{ value: "week", label: "Weekly" },
{ value: "month", label: "Monthly" },
]}
/>
<StatTile label="Total Tokens" value={String(costSummary?.summary.total_tokens || 0)} />
<StatTile label="Avg Latency" value={`${Math.round(costSummary?.summary.avg_latency_ms || 0)} ms`} />
<StatTile label="Total Cost" value={`$${(costSummary?.summary.total_cost || 0).toFixed(4)}`} />
</div>
<DataTable
data={costSummary?.by_provider || []}
columns={providerCostColumns}
keyExtractor={(item) => `${item.provider}-${item.completions}`}
emptyMessage="No cost data available"
isLoading={isLoading}
/>
</Card>
</div>
</div>
<Card title="Providers & Recent Completions">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<DataTable
data={providers}
columns={providerColumns}
keyExtractor={(item) => item.name}
emptyMessage="No providers available"
isLoading={isLoading}
/>
<DataTable
data={completions}
columns={completionColumns}
keyExtractor={(item) => item.id}
emptyMessage="No completions yet"
isLoading={isLoading}
/>
</div>
</Card>
</>
)}
{activeTab === "config" && (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<Card
title="Configure Tenant Provider"
description="Set provider-specific API key, endpoint, and model mapping for this tenant."
>
<FormSelect
label="Provider"
value={configForm.provider}
onValueChange={(value) => setConfigForm((prev) => ({ ...prev, provider: value }))}
options={providerOptions}
/>
<FormSelect
label="Config Type"
value={configForm.config_type}
onValueChange={(value) => setConfigForm((prev) => ({ ...prev, config_type: value }))}
options={[
{ value: "direct", label: "Direct Endpoint" },
{ value: "azure", label: "Azure" },
]}
/>
<FormField
label="API Key"
type="password"
value={configForm.api_key}
onChange={(e) => setConfigForm((prev) => ({ ...prev, api_key: e.target.value }))}
required
/>
<FormField
label="Endpoint"
value={configForm.endpoint}
onChange={(e) => setConfigForm((prev) => ({ ...prev, endpoint: e.target.value }))}
placeholder="http://host.docker.internal:11434/v1"
/>
<FormField
label="Default Model"
value={configForm.default_model}
onChange={(e) => setConfigForm((prev) => ({ ...prev, default_model: e.target.value }))}
/>
<FormField
label="Custom Models (comma separated)"
value={configForm.custom_models}
onChange={(e) => setConfigForm((prev) => ({ ...prev, custom_models: e.target.value }))}
placeholder="mistral-small3.2:24b,llama3.2"
/>
<div className="flex gap-2">
<PrimaryButton onClick={handleSaveConfig} disabled={savingConfig}>
{savingConfig ? "Saving..." : "Save Configuration"}
</PrimaryButton>
<SecondaryButton onClick={() => void loadConfigData()}>Reload</SecondaryButton>
</div>
</Card>
<div className="xl:col-span-2 space-y-4">
<Card
title="Configured Tenant Providers"
description="Use Test before go-live. Delete removes tenant override and reverts to gateway defaults."
>
<DataTable
data={configs}
columns={configColumns}
keyExtractor={(item) => item.id}
emptyMessage="No tenant provider configs found"
isLoading={isLoading}
/>
</Card>
<Card
title="Docker Endpoint Tip"
description="For containerized model servers use `host.docker.internal` from backend container to host machine."
>
<div className="text-sm text-[#334155]">
Example endpoint:
<code className="ml-2 bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded px-2 py-1 text-xs">
http://host.docker.internal:11434/v1
</code>
</div>
</Card>
</div>
</div>
)}
{activeTab === "prompts" && (
<div className="space-y-4">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<Card
title="Prompt Management"
description="Create reusable prompts with provider/model defaults and variable placeholders."
>
<FormField
label="Prompt Name"
value={promptForm.name}
onChange={(e) => setPromptForm((prev) => ({ ...prev, name: e.target.value }))}
required
/>
<FormField
label="Use Case"
value={promptForm.use_case}
onChange={(e) => setPromptForm((prev) => ({ ...prev, use_case: e.target.value }))}
required
/>
<FormTextArea
label="System Message"
value={promptForm.system_message}
onChange={(e) => setPromptForm((prev) => ({ ...prev, system_message: e.target.value }))}
/>
<FormTextArea
label="User Template"
value={promptForm.user_template}
onChange={(e) => setPromptForm((prev) => ({ ...prev, user_template: e.target.value }))}
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormSelect
label="Provider"
value={promptForm.provider}
onValueChange={(value) => setPromptForm((prev) => ({ ...prev, provider: value }))}
options={providerOptions}
placeholder="Auto"
/>
<FormSelect
label="Model"
value={promptForm.model}
onValueChange={(value) => setPromptForm((prev) => ({ ...prev, model: value }))}
options={modelOptions}
placeholder="Provider default"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<FormField
label="Temperature"
type="number"
value={promptForm.temperature}
onChange={(e) => setPromptForm((prev) => ({ ...prev, temperature: e.target.value }))}
/>
<FormField
label="Max Tokens"
type="number"
value={promptForm.max_tokens}
onChange={(e) => setPromptForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
/>
</div>
<div className="flex gap-2">
<PrimaryButton onClick={handleCreatePrompt} disabled={creatingPrompt}>
{creatingPrompt ? "Creating..." : "Create Prompt"}
</PrimaryButton>
<SecondaryButton onClick={() => void loadPromptData()}>Refresh</SecondaryButton>
</div>
</Card>
<Card
title="Prompt Testing"
description="Test prompt output with input variables before activating in business flow."
>
<FormSelect
label="Prompt"
value={executePromptId}
onValueChange={setExecutePromptId}
options={prompts.map((p) => ({
value: p.id,
label: `${p.name} (${p.status || "draft"})`,
}))}
/>
<FormTextArea
label="Variables (JSON)"
helperText='Example: {"topic":"deviation handling"}'
value={executeVariables}
onChange={(e) => setExecuteVariables(e.target.value)}
/>
<div className="flex gap-2">
<PrimaryButton onClick={handleExecutePrompt}>Run Prompt Test</PrimaryButton>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<StatusBadge variant="process">{executeMeta.provider || "Provider: -"}</StatusBadge>
<StatusBadge variant="process">{executeMeta.model || "Model: -"}</StatusBadge>
</div>
<div className="mt-3 bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-md p-3 min-h-[180px]">
<p className="text-sm text-[#0f1724] whitespace-pre-wrap">{executeResult || "No test output yet."}</p>
</div>
</Card>
</div>
<Card
title="Prompt Library"
description="Activate prompt versions for production execution."
>
<DataTable
data={prompts}
columns={promptColumns}
keyExtractor={(item) => item.id}
emptyMessage="No prompts found"
isLoading={isLoading}
/>
</Card>
</div>
)}
{activeTab === "knowledge" && (
<div className="space-y-4">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4">
<Card
title="Create Collection"
description="Collections help isolate domain documents for relevant retrieval."
>
<FormField
label="Collection Name"
value={collectionForm.name}
onChange={(e) => setCollectionForm((prev) => ({ ...prev, name: e.target.value }))}
required
/>
<FormField
label="Description"
value={collectionForm.description}
onChange={(e) => setCollectionForm((prev) => ({ ...prev, description: e.target.value }))}
/>
<PrimaryButton onClick={handleCreateCollection} disabled={creatingCollection}>
{creatingCollection ? "Creating..." : "Create Collection"}
</PrimaryButton>
</Card>
<Card
title="Ingest Document"
description="Upload supported file types and process them into vectors."
>
<FormSelect
label="Collection"
value={uploadCollectionId}
onValueChange={setUploadCollectionId}
options={collectionOptions}
/>
<div className="flex flex-col gap-2 pb-4">
<label className="text-[13px] font-medium text-[#0e1b2a]">Document File</label>
<input
type="file"
onChange={(e) => setUploadFile(e.target.files?.[0] || null)}
className="h-10 w-full px-3.5 py-1 bg-white border border-[rgba(0,0,0,0.08)] rounded-md text-sm"
/>
</div>
<PrimaryButton onClick={handleUploadDoc} disabled={uploadingDoc}>
{uploadingDoc ? "Uploading..." : "Upload & Ingest"}
</PrimaryButton>
</Card>
<Card
title="RAG Context Search"
description="Retrieve context passages before running grounded completion."
>
<FormSelect
label="Collection"
value={searchCollectionId}
onValueChange={setSearchCollectionId}
options={collectionOptions}
placeholder="All collections"
/>
<FormTextArea
label="Search Query"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<div className="flex gap-2">
<PrimaryButton onClick={handleSearchRag}>Search Context</PrimaryButton>
<SecondaryButton onClick={() => void loadKnowledgeData()}>Refresh</SecondaryButton>
</div>
</Card>
</div>
<Card title="RAG Output">
<div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-md p-3 min-h-[180px]">
<p className="text-sm text-[#0f1724] whitespace-pre-wrap">{searchResult || "No context result yet."}</p>
</div>
</Card>
<Card title="Knowledge Collections">
<DataTable
data={collections}
columns={collectionColumns}
keyExtractor={(item) => item.id}
emptyMessage="No collections available"
isLoading={isLoading}
/>
</Card>
</div>
)}
</div>
</Layout>
);
};
export default AIGateway;

View File

@ -0,0 +1,342 @@
import { useEffect, useMemo, useRef, useState, type ReactElement } from "react";
import { Layout } from "@/components/layout/Layout";
import { FormField, FormSelect, PrimaryButton, SecondaryButton, StatusBadge } from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type { AIProviderInfo } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { Bot, Send, User } from "lucide-react";
const CompletionCreate = (): ReactElement => {
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isSending, setIsSending] = useState<boolean>(false);
const [isPlaygroundMode, setIsPlaygroundMode] = useState<boolean>(true);
const [form, setForm] = useState({
user: "",
provider: "gemini",
model: "",
temperature: "0.7",
max_tokens: "1024",
});
const [lastSentUserMessage, setLastSentUserMessage] = useState<string>("");
const [displayedResponse, setDisplayedResponse] = useState<string>("");
const typingIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [responseData, setResponseData] = useState({
content: "",
provider: "",
model: "",
prompt_tokens: 0,
completion_tokens: 0,
total_tokens: 0,
cost: 0,
latency_ms: 0,
fallbackUsed: false,
});
const providerOptions = useMemo(
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
[providers],
);
const modelOptions = useMemo(
() =>
models
.filter((m) => !form.provider || m.provider === form.provider)
.map((m) => ({
value: m.id,
label: `${m.id}${m.isDefault ? " • default" : ""}`,
})),
[models, form.provider],
);
const loadOptions = async (): Promise<void> => {
setIsLoading(true);
try {
const [providerData, modelData] = await Promise.all([aiService.getProviders(), aiService.getModels()]);
setProviders(providerData);
setModels(modelData);
} catch (err: any) {
showToast.error(err?.response?.data?.error?.message || "Failed to load provider/model options");
} finally {
setIsLoading(false);
}
};
useEffect(() => {
void loadOptions();
}, []);
useEffect(() => {
return () => {
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
}
};
}, []);
const handleSend = async (): Promise<void> => {
const userMessage = form.user.trim();
if (!userMessage) {
showToast.error("Please enter a message before sending");
return;
}
if (typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
}
setLastSentUserMessage(userMessage);
setDisplayedResponse("");
setIsSending(true);
try {
const result = isPlaygroundMode
? await aiService.playground({
messages: [{ role: "user", content: userMessage }],
provider: form.provider || undefined,
model: form.model || undefined,
temperature: Number(form.temperature),
max_tokens: Number(form.max_tokens),
})
: await aiService.createCompletion({
messages: [{ role: "user", content: userMessage }],
provider: form.provider || undefined,
model: form.model || undefined,
temperature: Number(form.temperature),
max_tokens: Number(form.max_tokens),
});
setResponseData({
content: result.content || "",
provider: result.provider || "",
model: result.model || "",
prompt_tokens: result.usage?.prompt_tokens || 0,
completion_tokens: result.usage?.completion_tokens || 0,
total_tokens: result.usage?.total_tokens || 0,
cost: result.cost || 0,
latency_ms: result.latency_ms || 0,
fallbackUsed: Boolean(result.fallbackUsed),
});
const fullText = result.content || "";
if (!fullText) {
setDisplayedResponse("");
} else {
let index = 0;
typingIntervalRef.current = setInterval(() => {
index += 1;
setDisplayedResponse(fullText.slice(0, index));
if (index >= fullText.length && typingIntervalRef.current) {
clearInterval(typingIntervalRef.current);
typingIntervalRef.current = null;
}
}, 12);
}
showToast.success(
isPlaygroundMode ? "Playground response received" : "Completion created and saved to history",
);
} catch (err: any) {
showToast.error(
err?.response?.data?.error?.message ||
(isPlaygroundMode ? "Playground request failed" : "Failed to create completion"),
);
} finally {
setIsSending(false);
}
};
return (
<Layout
currentPage="Create Completion"
pageHeader={{
title: "Completions Playground",
description:
"Switch between sandbox testing and persisted completion creation, matching the playground workflow.",
}}
>
<div className="grid grid-cols-1 lg:grid-cols-[minmax(0,1fr)_320px] 2xl:grid-cols-[minmax(0,1fr)_360px] gap-3 md:gap-4">
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden min-h-[420px] md:min-h-[560px] lg:min-h-[640px] flex flex-col order-2 lg:order-1">
<div className="flex-1 p-3 md:p-4 lg:p-5 bg-[#fcfdff]">
<div className="max-w-[860px] mx-auto space-y-3 md:space-y-4">
{!lastSentUserMessage && !isSending && !displayedResponse ? (
<div className="h-full min-h-[260px] md:min-h-[320px] flex items-center justify-center">
<p className="text-xs md:text-sm text-[#94a3b8] text-center px-4">
Start by typing a message and click Send.
</p>
</div>
) : (
<>
{lastSentUserMessage && (
<>
<div className="flex items-center justify-end gap-2">
<p className="text-[10px] md:text-[11px] font-semibold text-[#6b7280] uppercase">You</p>
<User className="w-3.5 h-3.5 text-[#6b7280]" />
</div>
<div className="ml-auto max-w-[92%] sm:max-w-[86%] md:max-w-[80%] rounded-xl border border-[rgba(0,0,0,0.08)] bg-white px-3 py-2 text-sm text-[#0f1724] shadow-[0px_2px_10px_rgba(0,0,0,0.03)] break-words">
{lastSentUserMessage}
</div>
</>
)}
{isSending && (
<>
<div className="flex items-center gap-2">
<Bot className="w-3.5 h-3.5 text-[#6b7280]" />
<p className="text-[10px] md:text-[11px] font-semibold text-[#6b7280] uppercase">Calling LLM</p>
</div>
<div className="max-w-[180px] md:max-w-[220px] rounded-xl border border-[rgba(0,0,0,0.08)] bg-white px-3 py-2 text-lg text-[#6b7280]">
...
</div>
</>
)}
{(displayedResponse || responseData.content) && !isSending && (
<>
<div className="flex items-center gap-2">
<Bot className="w-3.5 h-3.5 text-[#6b7280]" />
<p className="text-[10px] md:text-[11px] font-semibold text-[#6b7280] uppercase">Assistant</p>
</div>
<div className="w-full max-w-[96%] sm:max-w-[92%] md:max-w-[88%] rounded-xl border-2 border-[#3B82F6] bg-white px-3 md:px-4 py-3 min-h-[160px] md:min-h-[200px]">
<p className="text-sm text-[#0f1724] whitespace-pre-wrap break-words">
{displayedResponse || responseData.content}
</p>
</div>
</>
)}
</>
)}
{(displayedResponse || responseData.content) && (
<div className="pt-1 flex flex-wrap gap-1.5 md:gap-2">
<StatusBadge variant="process">{responseData.provider || "Provider -"}</StatusBadge>
<StatusBadge variant="process">{responseData.model || "Model -"}</StatusBadge>
<StatusBadge variant="success">{responseData.latency_ms ? `${responseData.latency_ms} ms` : "Latency -"}</StatusBadge>
<StatusBadge variant={responseData.fallbackUsed ? "failure" : "success"}>
{responseData.fallbackUsed ? "Fallback Used" : "No Fallback"}
</StatusBadge>
</div>
)}
</div>
</div>
<div className="p-2.5 md:p-3 border-t border-[rgba(0,0,0,0.08)] bg-white">
<div className="flex gap-2 items-center">
<input
value={form.user}
onChange={(e) => setForm((prev) => ({ ...prev, user: e.target.value }))}
placeholder="Type your message here..."
className="flex-1 h-10 md:h-11 px-3 rounded-lg border border-[rgba(0,0,0,0.12)] text-sm text-[#0f1724] placeholder:text-[#94a3b8] focus:outline-none focus:ring-2 focus:ring-[#112868]/20"
/>
<PrimaryButton
onClick={handleSend}
disabled={isSending || isLoading}
className="h-10 md:h-11 px-3 md:px-4 min-w-[84px] md:min-w-[96px] shrink-0"
>
<Send className="w-3.5 h-3.5 mr-1.5" />
<span className="hidden sm:inline">{isSending ? "Sending..." : "Send"}</span>
</PrimaryButton>
</div>
</div>
</section>
<aside className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-3 md:p-4 h-fit order-1 lg:order-2 lg:sticky lg:top-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-semibold text-[#0f1724]">Playground Mode</h3>
<button
type="button"
onClick={() => setIsPlaygroundMode((prev) => !prev)}
className={`w-10 h-5 rounded-full relative transition-colors ${isPlaygroundMode ? "bg-[#3B82F6]" : "bg-[#e2e8f0]"}`}
aria-label="Toggle playground mode"
>
<span
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white border border-[rgba(0,0,0,0.1)] transition-all ${
isPlaygroundMode ? "right-0.5" : "left-0.5"
}`}
/>
</button>
</div>
<p className="text-xs text-[#6b7280] mb-4">
{isPlaygroundMode
? "Enabled: sends POST `/ai/playground` (non-persistent)."
: "Disabled: sends POST `/ai/completions` (saved in history)."}
</p>
<h3 className="text-sm font-semibold text-[#0f1724] mb-3">Model Configuration</h3>
<FormSelect
label="Provider"
value={form.provider}
options={providerOptions}
onValueChange={(value) => setForm((prev) => ({ ...prev, provider: value, model: "" }))}
/>
<FormSelect
label="Model"
value={form.model}
options={modelOptions}
placeholder="Provider default"
onValueChange={(value) => setForm((prev) => ({ ...prev, model: value }))}
/>
{!isPlaygroundMode && (
<FormField
label="Temperature"
type="number"
value={form.temperature}
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
/>
)}
<input
type="range"
min="0"
max="2"
step="0.1"
value={Number(form.temperature)}
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
className={`w-full ${isPlaygroundMode ? "mb-3 mt-0" : "-mt-2 mb-3"}`}
/>
<FormField
label="Max Tokens"
type="number"
value={form.max_tokens}
onChange={(e) => setForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
/>
<div className="mt-2 mb-4 flex gap-2">
<SecondaryButton onClick={() => void loadOptions()}>Reload Options</SecondaryButton>
</div>
<div className="border border-[rgba(0,0,0,0.08)] rounded-lg p-3 bg-[#f8fafc]">
<p className="text-xs font-semibold text-[#6b7280] uppercase mb-2">Last Request Stats</p>
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded p-2">
<p className="text-[11px] text-[#6b7280]">Prompt Tokens</p>
<p className="font-semibold text-[#0f1724]">{responseData.prompt_tokens}</p>
</div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded p-2">
<p className="text-[11px] text-[#6b7280]">Completion Tokens</p>
<p className="font-semibold text-[#0f1724]">{responseData.completion_tokens}</p>
</div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded p-2">
<p className="text-[11px] text-[#6b7280]">Total Tokens</p>
<p className="font-semibold text-[#0f1724]">{responseData.total_tokens}</p>
</div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded p-2">
<p className="text-[11px] text-[#6b7280]">Cost (USD)</p>
<p className="font-semibold text-[#0f1724]">${responseData.cost.toFixed(4)}</p>
</div>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded p-2 col-span-2">
<p className="text-[11px] text-[#6b7280]">Latency</p>
<p className="font-semibold text-[#0f1724]">{responseData.latency_ms} ms</p>
</div>
</div>
</div>
</aside>
</div>
</Layout>
);
};
export default CompletionCreate;

View File

@ -0,0 +1,184 @@
import { useEffect, useState, type ReactElement, type ReactNode } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft } from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import { PrimaryButton, SecondaryButton, StatusBadge } from "@/components/shared";
import { aiService } from "@/services/ai-service";
import type { AICompletion } from "@/types/ai";
import { showToast } from "@/utils/toast";
const formatWhen = (value?: string | null): string => {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
};
const DetailRow = ({ label, value }: { label: string; value: ReactNode }): ReactElement => (
<div className="grid grid-cols-1 sm:grid-cols-[minmax(140px,200px)_1fr] gap-1 sm:gap-3 py-2 border-b border-[rgba(0,0,0,0.06)] last:border-0">
<dt className="text-xs font-medium text-[#64748b]">{label}</dt>
<dd className="text-sm text-[#0f1724] break-words">{value}</dd>
</div>
);
const CompletionDetail = (): ReactElement => {
const { completionId } = useParams<{ completionId: string }>();
const navigate = useNavigate();
const [row, setRow] = useState<AICompletion | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!completionId) return;
let cancelled = false;
const load = async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const data = await aiService.getCompletion(completionId);
if (!cancelled) setRow(data);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error
?.message || "Failed to load completion";
if (!cancelled) {
setError(msg);
showToast.error(msg);
}
} finally {
if (!cancelled) setIsLoading(false);
}
};
void load();
return () => {
cancelled = true;
};
}, [completionId]);
return (
<Layout
currentPage="Completion History"
pageHeader={{
title: "Completion details",
description: "Full request, response, and usage for this completion.",
action: (
<SecondaryButton type="button" onClick={() => navigate("/tenant/ai/completions")}>
<ArrowLeft className="w-4 h-4 mr-1.5 inline" />
Back to list
</SecondaryButton>
),
}}
>
<div className="space-y-5">
{isLoading && (
<p className="text-sm text-[#64748b] px-1">Loading completion</p>
)}
{error && !isLoading && (
<div className="rounded-lg border border-[rgba(0,0,0,0.08)] bg-white p-5">
<p className="text-sm text-red-600">{error}</p>
<PrimaryButton className="mt-4" onClick={() => navigate("/tenant/ai/completions")}>
Return to list
</PrimaryButton>
</div>
)}
{row && !isLoading && (
<>
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="px-4 md:px-5 py-4 border-b border-[rgba(0,0,0,0.08)] flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-[#0f1724]">Summary</h2>
<p className="text-xs text-[#64748b] mt-0.5">ID: {row.id}</p>
</div>
<StatusBadge variant={row.status === "completed" ? "success" : "failure"}>
{row.status || "unknown"}
</StatusBadge>
</div>
<div className="px-4 md:px-5 py-3">
<dl>
<DetailRow label="Created" value={formatWhen(row.created_at)} />
<DetailRow label="Completed" value={formatWhen(row.completed_at)} />
<DetailRow label="Module" value={row.module_name || "—"} />
<DetailRow label="Module ID" value={row.module_id || "—"} />
<DetailRow label="User" value={row.user_name || "—"} />
<DetailRow label="User ID" value={row.user_id || "—"} />
<DetailRow label="Provider / model" value={`${row.provider} / ${row.model}`} />
<DetailRow label="Use case" value={row.use_case || "—"} />
<DetailRow label="Correlation" value={row.correlation_id || "—"} />
<DetailRow
label="Tokens"
value={`${row.usage?.total_tokens ?? row.total_tokens ?? 0} (prompt ${row.usage?.prompt_tokens ?? row.prompt_tokens ?? 0}, completion ${row.usage?.completion_tokens ?? row.completion_tokens ?? 0})`}
/>
<DetailRow label="Cost (USD)" value={String(row.cost ?? 0)} />
<DetailRow label="Latency" value={`${row.latency_ms ?? 0} ms`} />
<DetailRow label="Temperature" value={row.temperature != null ? String(row.temperature) : "—"} />
<DetailRow label="Max tokens" value={row.max_tokens != null ? String(row.max_tokens) : "—"} />
<DetailRow label="Top p" value={row.top_p != null ? String(row.top_p) : "—"} />
<DetailRow label="Cached" value={row.cached != null ? (row.cached ? "Yes" : "No") : "—"} />
<DetailRow label="Streaming" value={row.streaming != null ? (row.streaming ? "Yes" : "No") : "—"} />
<DetailRow label="Fallback provider" value={row.fallback_provider || "—"} />
{row.error_message && (
<DetailRow label="Error" value={<span className="text-red-600">{row.error_message}</span>} />
)}
{row.error_code && <DetailRow label="Error code" value={row.error_code} />}
</dl>
</div>
</section>
{row.system_message && (
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="px-4 md:px-5 py-3 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0f1724]">System message</h3>
</div>
<pre className="px-4 md:px-5 py-4 text-xs text-[#334155] whitespace-pre-wrap break-words max-h-[320px] overflow-y-auto bg-[#f8fafc]">
{row.system_message}
</pre>
</section>
)}
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="px-4 md:px-5 py-3 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0f1724]">Prompt</h3>
</div>
<pre className="px-4 md:px-5 py-4 text-xs text-[#334155] whitespace-pre-wrap break-words max-h-[480px] overflow-y-auto bg-[#f8fafc]">
{row.prompt || "—"}
</pre>
</section>
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="px-4 md:px-5 py-3 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0f1724]">Response</h3>
</div>
<pre className="px-4 md:px-5 py-4 text-xs text-[#334155] whitespace-pre-wrap break-words max-h-[480px] overflow-y-auto bg-[#f8fafc]">
{row.response || row.content || "—"}
</pre>
</section>
{row.metadata != null && (
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="px-4 md:px-5 py-3 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm font-semibold text-[#0f1724]">Metadata</h3>
</div>
<pre className="px-4 md:px-5 py-4 text-xs text-[#334155] whitespace-pre-wrap break-words max-h-[320px] overflow-y-auto bg-[#f8fafc]">
{typeof row.metadata === "string"
? row.metadata
: JSON.stringify(row.metadata, null, 2)}
</pre>
</section>
)}
<div className="flex gap-2">
<PrimaryButton type="button" onClick={() => navigate("/tenant/ai/completions")}>
Back to list
</PrimaryButton>
<SecondaryButton type="button" onClick={() => navigate("/tenant/ai/completions/create")}>
New completion
</SecondaryButton>
</div>
</>
)}
</div>
</Layout>
);
};
export default CompletionDetail;

View File

@ -0,0 +1,493 @@
import { useCallback, useEffect, useMemo, useState, type ReactElement } from "react";
import { useNavigate } from "react-router-dom";
import { ChevronDown, SlidersHorizontal } from "lucide-react";
import { Layout } from "@/components/layout/Layout";
import {
DataTable,
type Column,
FilterDropdown,
Pagination,
PrimaryButton,
StatusBadge,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import { moduleService } from "@/services/module-service";
import { tenantService, type TenantUserDropdownItem } from "@/services/tenant-service";
import type { AICompletion, AIProviderInfo } from "@/types/ai";
import type { MyModule } from "@/types/module";
import { showToast } from "@/utils/toast";
import { cn } from "@/lib/utils";
import { useAppTheme } from "@/hooks/useAppTheme";
const formatListDate = (value?: string | null): string => {
if (!value) return "—";
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const CompletionHistory = (): ReactElement => {
const navigate = useNavigate();
const { primaryColor } = useAppTheme();
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isMetaLoading, setIsMetaLoading] = useState<boolean>(true);
const [providers, setProviders] = useState<AIProviderInfo[]>([]);
const [models, setModels] = useState<Array<{ id: string; provider: string; isDefault?: boolean }>>([]);
const [modules, setModules] = useState<MyModule[]>([]);
const [tenantUsers, setTenantUsers] = useState<TenantUserDropdownItem[]>([]);
const [completions, setCompletions] = useState<AICompletion[]>([]);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [page, setPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(10);
const [pagination, setPagination] = useState({
page: 1,
limit: 10,
total: 0,
totalPages: 1,
});
const [showMoreFilters, setShowMoreFilters] = useState<boolean>(false);
const [filters, setFilters] = useState({
provider: null as string | null,
model: null as string | null,
status: null as string | null,
moduleId: null as string | null,
userId: null as string | null,
startDate: "",
endDate: "",
});
const providerOptions = useMemo(
() => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
[providers],
);
const modelOptions = useMemo(
() =>
models.map((m) => ({
value: m.id,
label: `${m.provider} · ${m.id}`,
})),
[models],
);
const moduleOptions = useMemo(
() => modules.map((m) => ({ value: m.id, label: m.name })),
[modules],
);
const userOptions = useMemo(
() =>
tenantUsers.map((u) => ({
value: u.id,
label: `${u.name} (${u.role ?? "—"})`,
})),
[tenantUsers],
);
const hasExtraFilters = Boolean(
filters.moduleId || filters.userId || filters.startDate || filters.endDate,
);
useEffect(() => {
if (hasExtraFilters) {
setShowMoreFilters(true);
}
}, [hasExtraFilters]);
const loadMeta = useCallback(async (): Promise<void> => {
setIsMetaLoading(true);
try {
const [providerData, modelData, modulesRes, usersData] = await Promise.all([
aiService.getProviders(),
aiService.getModels(),
moduleService.getMyModules(),
tenantService.getCurrentTenantUsersDropdown(),
]);
setProviders(providerData);
setModels(modelData);
setModules(modulesRes.data || []);
setTenantUsers(usersData);
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error
?.message || "Failed to load filter options";
showToast.error(msg);
} finally {
setIsMetaLoading(false);
}
}, []);
const loadCompletions = useCallback(async (): Promise<void> => {
setIsLoading(true);
setError(null);
try {
const listData = await aiService.listCompletions({
page,
limit,
provider: filters.provider || undefined,
model: filters.model || undefined,
status: filters.status || undefined,
user_id: filters.userId || undefined,
module_id: filters.moduleId || undefined,
start_date: filters.startDate.trim() || undefined,
end_date: filters.endDate.trim() || undefined,
});
setCompletions(listData.data || []);
setExpandedId(null);
setPagination({
page: listData.pagination?.page || page,
limit: listData.pagination?.limit || limit,
total: listData.pagination?.total || 0,
totalPages: listData.pagination?.totalPages || 1,
});
} catch (err: unknown) {
const msg =
(err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error
?.message || "Failed to load completion history";
setError(msg);
showToast.error(msg);
} finally {
setIsLoading(false);
}
}, [
page,
limit,
filters.provider,
filters.model,
filters.status,
filters.userId,
filters.moduleId,
filters.startDate,
filters.endDate,
]);
useEffect(() => {
void loadMeta();
}, [loadMeta]);
useEffect(() => {
void loadCompletions();
}, [loadCompletions]);
const clearFilters = (): void => {
setPage(1);
setFilters({
provider: null,
model: null,
status: null,
moduleId: null,
userId: null,
startDate: "",
endDate: "",
});
setShowMoreFilters(false);
};
const toggleExpand = (id: string): void => {
setExpandedId((prev) => (prev === id ? null : id));
};
const renderExpanded = (row: AICompletion): ReactElement => {
const total =
row.usage?.total_tokens ?? row.total_tokens ?? (row.prompt_tokens ?? 0) + (row.completion_tokens ?? 0);
const preview = (row.response || row.content || "").slice(0, 800);
return (
<div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.06)] rounded-md p-3 md:p-4 text-xs text-[#334155] space-y-2">
{/* <p>
<span className="font-semibold text-[#475569]">IDs </span>
Module: <code className="text-[11px]">{row.module_id || "—"}</code>
{" · "}
User: <code className="text-[11px]">{row.user_id || "—"}</code>
</p>
<p>
<span className="font-semibold text-[#475569]">Correlation </span>
{row.correlation_id || "—"}
</p> */}
<p>
<span className="font-semibold text-[#475569]">Tokens / cost / latency </span>
{`${total} tokens · USD ${Number(row.cost ?? 0).toFixed(6)} · ${row.latency_ms ?? 0} ms`}
</p>
{row.use_case && (
<p>
<span className="font-semibold text-[#475569]">Use case </span>
{row.use_case}
</p>
)}
{(row.error_message || row.error_code) && (
<p className="text-red-600">
<span className="font-semibold">Error </span>
{[row.error_code, row.error_message].filter(Boolean).join(" · ")}
</p>
)}
<div>
<span className="font-semibold text-[#475569] block mb-1">Response preview</span>
<p className="whitespace-pre-wrap break-words leading-relaxed">{preview || "—"}</p>
</div>
<button
type="button"
className="text-xs font-semibold hover:underline"
style={{ color: primaryColor }}
onClick={() => navigate(`/tenant/ai/completions/${row.id}`)}
>
Open full detail view
</button>
</div>
);
};
const columns: Column<AICompletion>[] = [
{
key: "date",
label: "Date",
render: (row) => <span className="whitespace-nowrap">{formatListDate(row.created_at)}</span>,
},
{
key: "module_name",
label: "Module",
render: (row) => <span className="line-clamp-2">{row.module_name || "Platform"}</span>,
},
{
key: "user_name",
label: "User",
render: (row) => <span className="line-clamp-2">{row.user_name || "—"}</span>,
},
{ key: "provider", label: "Provider", render: (row) => row.provider || "—" },
{
key: "model",
label: "Model",
render: (row) => <span className="line-clamp-2">{row.model || "—"}</span>,
},
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={row.status === "completed" ? "success" : "failure"}>
{row.status || "unknown"}
</StatusBadge>
),
},
{
key: "view",
label: "View",
align: "right",
render: (row) => (
<button
type="button"
className="text-xs font-semibold hover:underline"
style={{ color: primaryColor }}
onClick={(e) => {
e.stopPropagation();
navigate(`/tenant/ai/completions/${row.id}`);
}}
>
View
</button>
),
},
];
return (
<Layout
currentPage="Completion History"
pageHeader={{
title: "Completion History",
description:
"Track provider/model/token usage for persisted completions.",
action: (
<PrimaryButton onClick={() => navigate("/tenant/ai/completions/create")}>
Create Completion
</PrimaryButton>
),
}}
>
<div className="space-y-5">
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="p-4 md:p-5 border-b border-[rgba(0,0,0,0.08)]">
<h3 className="text-sm md:text-base font-semibold text-[#0f1724] mb-3">
Completion List
</h3>
<div className="flex flex-col gap-3">
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Provider"
options={providerOptions}
value={filters.provider}
placeholder="All providers"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, provider: value as string | null }));
}}
/>
<FilterDropdown
label="Model"
options={modelOptions}
value={filters.model}
placeholder="All models"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, model: value as string | null }));
}}
/>
<FilterDropdown
label="Status"
options={[
{ value: "completed", label: "Completed" },
{ value: "failed", label: "Failed" },
{ value: "pending", label: "Pending" },
]}
value={filters.status}
placeholder="All statuses"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, status: value as string | null }));
}}
/>
<button
type="button"
onClick={() => setShowMoreFilters((open) => !open)}
className={cn(
"inline-flex items-center gap-1.5 h-10 px-3 rounded-md text-sm font-medium border bg-white transition-colors",
showMoreFilters || hasExtraFilters
? "border-[rgba(8,76,200,0.35)] text-[#0f1724]"
: "border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30",
)}
style={
showMoreFilters || hasExtraFilters
? { borderColor: `${primaryColor}55`, color: primaryColor }
: undefined
}
>
<SlidersHorizontal className="w-3.5 h-3.5 shrink-0" />
More filters
<ChevronDown
className={cn(
"w-3.5 h-3.5 shrink-0 opacity-70 transition-transform",
showMoreFilters && "rotate-180",
)}
/>
</button>
</div>
<button
type="button"
onClick={clearFilters}
className="text-sm font-medium text-[#6b7280] hover:text-[#0f1724] transition-colors self-start lg:self-center"
>
Clear filters
</button>
</div>
{showMoreFilters && (
<div className="flex flex-col gap-3 pt-3 border-t border-[rgba(0,0,0,0.08)]">
<div className="flex flex-wrap items-center gap-2">
<FilterDropdown
label="Module"
options={moduleOptions}
value={filters.moduleId}
placeholder="All modules"
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, moduleId: value as string | null }));
}}
/>
<FilterDropdown
label="User"
options={userOptions}
value={filters.userId}
placeholder="All users"
isSearchable
onChange={(value) => {
setPage(1);
setFilters((prev) => ({ ...prev, userId: value as string | null }));
}}
/>
</div>
<div className="flex flex-wrap items-center gap-4 md:gap-6">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">From:</span>
<input
type="date"
value={filters.startDate}
onChange={(e) => {
setPage(1);
setFilters((prev) => ({ ...prev, startDate: e.target.value }));
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-[#475569]">To:</span>
<input
type="date"
value={filters.endDate}
onChange={(e) => {
setPage(1);
setFilters((prev) => ({ ...prev, endDate: e.target.value }));
}}
className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20"
/>
</div>
</div>
</div>
)}
</div>
{isMetaLoading && (
<p className="text-xs text-[#64748b] mt-2">Loading filter options</p>
)}
</div>
<DataTable
data={completions}
columns={columns}
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No completion records found"
expandableRows
isRowExpanded={(row) => expandedId === row.id}
onRowExpandToggle={(row) => toggleExpand(row.id)}
renderExpandedRow={renderExpanded}
onRowClick={(row) => toggleExpand(row.id)}
/>
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 md:px-5 py-3">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={setPage}
onLimitChange={(newLimit) => {
setLimit(newLimit);
setPage(1);
}}
limitOptions={[
{ value: "5", label: "5 per page" },
{ value: "10", label: "10 per page" },
{ value: "20", label: "20 per page" },
]}
/>
</div>
)}
</section>
</div>
</Layout>
);
};
export default CompletionHistory;

View File

@ -0,0 +1,180 @@
// import { useEffect, useMemo, useState, type ReactElement } from "react";
// import { Layout } from "@/components/layout/Layout";
// import {
// FormField,
// FormSelect,
// FormTextArea,
// PrimaryButton,
// SecondaryButton,
// StatusBadge,
// } from "@/components/shared";
// import { aiService } from "@/services/ai-service";
// import type { AIProviderInfo } from "@/types/ai";
// import { showToast } from "@/utils/toast";
// const CompletionPlayground = (): ReactElement => {
// const [providers, setProviders] = useState<AIProviderInfo[]>([]);
// const [isLoading, setIsLoading] = useState<boolean>(false);
// const [isRunning, setIsRunning] = useState<boolean>(false);
// const [form, setForm] = useState({
// provider: "gemini",
// model: "",
// max_tokens: "10",
// temperature: "0.7",
// user: "ping",
// });
// const [result, setResult] = useState({
// content: "",
// provider: "",
// model: "",
// prompt_tokens: 0,
// completion_tokens: 0,
// total_tokens: 0,
// cost: 0,
// latency_ms: 0,
// fallbackUsed: false,
// });
// const providerOptions = useMemo(
// () => providers.map((p) => ({ value: p.name, label: p.displayName || p.name })),
// [providers],
// );
// const loadProviders = async (): Promise<void> => {
// setIsLoading(true);
// try {
// const data = await aiService.getProviders();
// setProviders(data);
// } catch (err: any) {
// showToast.error(err?.response?.data?.error?.message || "Failed to load providers");
// } finally {
// setIsLoading(false);
// }
// };
// useEffect(() => {
// void loadProviders();
// }, []);
// const handleRun = async (): Promise<void> => {
// setIsRunning(true);
// try {
// const response = await aiService.playground({
// messages: [{ role: "user", content: form.user }],
// provider: form.provider || undefined,
// model: form.model || undefined,
// max_tokens: Number(form.max_tokens),
// temperature: Number(form.temperature),
// });
// setResult({
// content: response.content || "",
// provider: response.provider || "",
// model: response.model || "",
// prompt_tokens: response.usage?.prompt_tokens || 0,
// completion_tokens: response.usage?.completion_tokens || 0,
// total_tokens: response.usage?.total_tokens || 0,
// cost: response.cost || 0,
// latency_ms: response.latency_ms || 0,
// fallbackUsed: Boolean(response.fallbackUsed),
// });
// showToast.success("Playground response received");
// } catch (err: any) {
// showToast.error(err?.response?.data?.error?.message || "Playground request failed");
// } finally {
// setIsRunning(false);
// }
// };
// return (
// <Layout
// currentPage="Completion Playground"
// pageHeader={{
// title: "Completion Playground",
// description:
// "Run quick non-persistent completion tests using /ai/playground (results are not stored in DB).",
// }}
// >
// <div className="space-y-5">
// <section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-5">
// <h3 className="text-sm md:text-base font-semibold text-[#0f1724] mb-1">
// Playground Request
// </h3>
// <p className="text-xs md:text-sm text-[#6b7280] mb-4">
// Uses <code>/ai/playground</code> for fast testing without history persistence.
// </p>
// <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
// <FormSelect
// label="Provider"
// value={form.provider}
// options={providerOptions}
// onValueChange={(value) => setForm((prev) => ({ ...prev, provider: value }))}
// />
// <FormField
// label="Model (optional)"
// value={form.model}
// onChange={(e) => setForm((prev) => ({ ...prev, model: e.target.value }))}
// placeholder="e.g. gemini-2.0-flash"
// />
// </div>
// <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
// <FormField
// label="Max Tokens"
// type="number"
// value={form.max_tokens}
// onChange={(e) => setForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
// />
// <FormField
// label="Temperature"
// type="number"
// value={form.temperature}
// onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
// />
// </div>
// <FormTextArea
// label="User Message"
// value={form.user}
// onChange={(e) => setForm((prev) => ({ ...prev, user: e.target.value }))}
// />
// <div className="flex gap-2">
// <PrimaryButton onClick={handleRun} disabled={isRunning || isLoading}>
// {isRunning ? "Running..." : "Run Playground"}
// </PrimaryButton>
// <SecondaryButton onClick={() => void loadProviders()}>Reload Providers</SecondaryButton>
// </div>
// </section>
// <section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg p-4 md:p-5">
// <h3 className="text-sm md:text-base font-semibold text-[#0f1724] mb-3">
// Playground Response
// </h3>
// <div className="flex flex-wrap gap-2 mb-3">
// <StatusBadge variant="process">Provider: {result.provider || "-"}</StatusBadge>
// <StatusBadge variant="process">Model: {result.model || "-"}</StatusBadge>
// <StatusBadge variant="process">Latency: {result.latency_ms || 0} ms</StatusBadge>
// <StatusBadge variant="process">
// Tokens: {result.prompt_tokens}/{result.completion_tokens}/{result.total_tokens}
// </StatusBadge>
// <StatusBadge variant={result.fallbackUsed ? "failure" : "success"}>
// Fallback: {result.fallbackUsed ? "Used" : "No"}
// </StatusBadge>
// </div>
// <div className="bg-[#f8fafc] border border-[rgba(0,0,0,0.08)] rounded-md p-3 min-h-[200px]">
// <p className="text-sm text-[#0f1724] whitespace-pre-wrap">
// {result.content || "No response yet."}
// </p>
// </div>
// </section>
// </div>
// </Layout>
// );
// };
// export default CompletionPlayground;

View File

@ -36,6 +36,10 @@ 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 CompletionHistory = lazy(() => import("@/pages/tenant/CompletionHistory"));
const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate"));
const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail"));
// Loading fallback component
const RouteLoader = (): ReactElement => (
@ -166,4 +170,32 @@ export const tenantAdminRoutes: RouteConfig[] = [
path: "/tenant/settings/failed-emails",
element: <LazyRoute component={FailedEmails} />,
},
{
path: "/tenant/ai",
element: <LazyRoute component={CompletionHistory} />,
},
{
path: "/tenant/ai/completions",
element: <LazyRoute component={CompletionHistory} />,
},
{
path: "/tenant/ai/completions/create",
element: <LazyRoute component={CompletionCreate} />,
},
{
path: "/tenant/ai/completions/:completionId",
element: <LazyRoute component={CompletionDetail} />,
},
{
path: "/tenant/ai/config",
element: <LazyRoute component={AIGateway} />,
},
{
path: "/tenant/ai/prompts",
element: <LazyRoute component={AIGateway} />,
},
{
path: "/tenant/ai/knowledge",
element: <LazyRoute component={AIGateway} />,
},
];

224
src/services/ai-service.ts Normal file
View File

@ -0,0 +1,224 @@
import apiClient from "@/services/api-client";
import type {
AICompletion,
AICompletionListResponse,
AICostSummary,
AIHealthResponse,
AIProviderInfo,
AIPrompt,
KnowledgeCollection,
KnowledgeSearchItem,
TenantAIConfig,
} from "@/types/ai";
const unwrap = <T>(response: any): T => {
if (response?.data?.data !== undefined) return response.data.data as T;
if (response?.data !== undefined) return response.data as T;
return response as T;
};
class AIService {
async getProviders(): Promise<AIProviderInfo[]> {
const response = await apiClient.get("/ai/providers");
return unwrap<AIProviderInfo[]>(response);
}
async getProviderHealth(provider: string): Promise<AIHealthResponse> {
const response = await apiClient.get(`/ai/providers/${encodeURIComponent(provider)}/health`);
return unwrap<AIHealthResponse>(response);
}
async getGatewayHealth(): Promise<AIHealthResponse> {
const response = await apiClient.get("/ai/health");
return unwrap<AIHealthResponse>(response);
}
async getModels(): Promise<Array<{ id: string; provider: string; isDefault?: boolean }>> {
const response = await apiClient.get("/ai/models");
return unwrap<Array<{ id: string; provider: string; isDefault?: boolean }>>(response);
}
async createCompletion(payload: {
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
}): Promise<AICompletion> {
const response = await apiClient.post("/ai/completions", payload);
return unwrap<AICompletion>(response);
}
async getCompletion(id: string): Promise<AICompletion> {
const response = await apiClient.get(`/ai/completions/${encodeURIComponent(id)}`);
return unwrap<AICompletion>(response);
}
async listCompletions(params: {
page?: number;
limit?: number;
provider?: string;
model?: string;
status?: string;
user_id?: string;
module_id?: string;
start_date?: string;
end_date?: string;
}): Promise<AICompletionListResponse> {
const response = await apiClient.get("/ai/completions", { params });
if (response?.data?.data && response?.data?.pagination) {
return { data: response.data.data, pagination: response.data.pagination };
}
return unwrap<AICompletionListResponse>(response);
}
async playground(payload: {
messages: Array<{ role: "system" | "user" | "assistant"; content: string }>;
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
}): Promise<AICompletion> {
const response = await apiClient.post("/ai/playground", payload);
return unwrap<AICompletion>(response);
}
async getCostSummary(params: {
group_by?: "day" | "week" | "month";
start_date?: string;
end_date?: string;
} = {}): Promise<AICostSummary> {
const response = await apiClient.get("/ai/costs", { params });
return unwrap<AICostSummary>(response);
}
async upsertConfig(payload: {
provider: string;
config_type: "azure" | "direct";
api_key: string;
display_name?: string;
endpoint?: string;
deployment?: string;
api_version?: string;
custom_models?: string[];
default_model?: string;
is_active?: boolean;
}): Promise<TenantAIConfig> {
const response = await apiClient.post("/ai/config", payload);
return unwrap<TenantAIConfig>(response);
}
async listConfigs(): Promise<TenantAIConfig[]> {
const response = await apiClient.get("/ai/config");
return unwrap<TenantAIConfig[]>(response);
}
async testConfig(provider: string): Promise<AIHealthResponse> {
const response = await apiClient.post(`/ai/config/${encodeURIComponent(provider)}/test`, {});
return unwrap<AIHealthResponse>(response);
}
async deleteConfig(provider: string): Promise<void> {
await apiClient.delete(`/ai/config/${encodeURIComponent(provider)}`);
}
async createPrompt(payload: {
name: string;
description?: string;
use_case: string;
system_message?: string;
user_template: string;
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
variables?: Array<{ name: string; type?: "string" | "number" | "boolean" | "array"; required?: boolean }>;
tags?: string[];
}): Promise<AIPrompt> {
const response = await apiClient.post("/ai/prompts", payload);
return unwrap<AIPrompt>(response);
}
async listPrompts(params: { page?: number; limit?: number; status?: string; search?: string } = {}): Promise<{
data: AIPrompt[];
pagination?: { page: number; limit: number; total: number; totalPages: number };
}> {
const response = await apiClient.get("/ai/prompts", { params });
if (response?.data?.data && response?.data?.pagination) {
return { data: response.data.data, pagination: response.data.pagination };
}
return unwrap(response);
}
async updatePromptStatus(id: string, status: "draft" | "active" | "archived" | "deprecated"): Promise<AIPrompt> {
const response = await apiClient.patch(`/ai/prompts/${id}/status`, { status });
return unwrap<AIPrompt>(response);
}
async executePrompt(
id: string,
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
): Promise<AICompletion> {
const response = await apiClient.post(`/ai/prompts/${id}/execute`, payload);
return unwrap<AICompletion>(response);
}
async testPrompt(
id: string,
payload: { variables?: Record<string, unknown>; provider?: string; model?: string; temperature?: number; max_tokens?: number },
): Promise<AICompletion> {
const response = await apiClient.post(`/ai/prompts/${id}/test`, payload);
return unwrap<AICompletion>(response);
}
async listCollections(params: { page?: number; limit?: number; status?: string } = {}): Promise<{
data: KnowledgeCollection[];
pagination?: { page: number; limit: number; total: number; totalPages: number };
}> {
const response = await apiClient.get("/ai/knowledge/collections", { params });
if (response?.data?.data && response?.data?.pagination) {
return { data: response.data.data, pagination: response.data.pagination };
}
return unwrap(response);
}
async createCollection(payload: { name: string; description?: string; metadata?: Record<string, unknown> }): Promise<KnowledgeCollection> {
const response = await apiClient.post("/ai/knowledge/collections", payload);
return unwrap<KnowledgeCollection>(response);
}
async uploadKnowledgeDocument(payload: { collectionId: string; file: File; provider?: string }): Promise<unknown> {
const formData = new FormData();
formData.append("collectionId", payload.collectionId);
formData.append("file", payload.file);
if (payload.provider) {
formData.append("provider", payload.provider);
}
const response = await apiClient.post("/ai/knowledge/documents", formData);
return unwrap(response);
}
async searchKnowledge(payload: {
query: string;
collectionId?: string;
provider?: string;
topK?: number;
minScore?: number;
}): Promise<KnowledgeSearchItem[]> {
const response = await apiClient.post("/ai/knowledge/search", payload);
return unwrap<KnowledgeSearchItem[]>(response);
}
async searchKnowledgeWithContext(payload: {
query: string;
collectionId?: string;
provider?: string;
topK?: number;
minScore?: number;
}): Promise<{ context?: string; matches?: KnowledgeSearchItem[] }> {
const response = await apiClient.post("/ai/knowledge/search/context", payload);
return unwrap<{ context?: string; matches?: KnowledgeSearchItem[] }>(response);
}
}
export const aiService = new AIService();

View File

@ -68,6 +68,11 @@ export const auditLogService = {
return response.data;
},
getModulesDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => {
const response = await apiClient.get('/modules/dropdown');
return response.data;
},
getAllResourceTypes: async (page = 1, limit = 50, filters: any = {}): Promise<any> => {
const params = new URLSearchParams();
params.append('page', String(page));

View File

@ -2,6 +2,10 @@ import apiClient from './api-client';
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse } from '@/types/module';
export const moduleService = {
getDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => {
const response = await apiClient.get('/modules/dropdown');
return response.data;
},
getAll: async (
page: number = 1,
limit: number = 20,

View File

@ -61,6 +61,12 @@ export interface DeleteTenantResponse {
message?: string;
}
export interface TenantUserDropdownItem {
id: string;
name: string;
role: string | null;
}
export const tenantService = {
getAll: async (
page: number = 1,
@ -98,4 +104,15 @@ export const tenantService = {
const response = await apiClient.delete<DeleteTenantResponse>(`/tenants/${id}`);
return response.data;
},
/** Active users in the current JWT tenant (for filter dropdowns: name + role). */
getCurrentTenantUsersDropdown: async (): Promise<TenantUserDropdownItem[]> => {
const response = await apiClient.get<{ success: boolean; data: TenantUserDropdownItem[] }>(
"/tenants/current/users/dropdown",
);
if (response?.data?.data !== undefined) {
return response.data.data;
}
return [];
},
};

158
src/types/ai.ts Normal file
View File

@ -0,0 +1,158 @@
export interface AIProviderInfo {
name: string;
displayName?: string;
isEnabled?: boolean;
models?: string[];
defaultModel?: string | null;
supportsStreaming?: boolean;
supportsEmbeddings?: boolean;
supportsMultimodal?: boolean;
}
export interface AIHealthResponse {
healthy: boolean;
latency_ms?: number;
provider?: string;
error?: string;
providers?: Record<string, { healthy: boolean; latency_ms?: number; error?: string }>;
}
export interface AIUsage {
prompt_tokens: number;
completion_tokens: number;
total_tokens: number;
}
export interface AICompletion {
id: string;
tenant_id?: string;
module_id?: string | null;
module_name?: string | null;
user_id?: string | null;
user_name?: string | null;
use_case?: string;
prompt_template_id?: string | null;
prompt?: string;
system_message?: string;
temperature?: number | null;
max_tokens?: number | null;
top_p?: number | null;
frequency_penalty?: number | null;
presence_penalty?: number | null;
stop_sequences?: unknown;
provider: string;
model: string;
content?: string;
response?: string;
usage?: AIUsage;
cost?: number;
latency_ms?: number;
fallbackUsed?: boolean;
cached?: boolean;
cache_key?: string | null;
streaming?: boolean;
fallback_provider?: string | null;
metadata?: unknown;
correlation_id?: string | null;
error_message?: string | null;
error_code?: string | null;
status?: string;
prompt_tokens?: number;
completion_tokens?: number;
total_tokens?: number;
completed_at?: string | null;
created_at?: string;
updated_at?: string;
}
export interface AICompletionListResponse {
data: AICompletion[];
pagination?: {
page: number;
limit: number;
total: number;
totalPages: number;
};
}
export interface TenantAIConfig {
id: string;
tenant_id: string;
provider: string;
config_type: "azure" | "direct";
display_name?: string;
api_key_masked?: string;
endpoint?: string | null;
deployment?: string | null;
api_version?: string | null;
custom_models?: string[];
custom_pricing?: Record<string, { input: number; output: number }>;
default_model?: string | null;
is_active?: boolean;
last_verified_at?: string | null;
last_error?: string | null;
}
export interface PromptVariable {
name: string;
type?: "string" | "number" | "boolean" | "array";
required?: boolean;
default?: unknown;
}
export interface AIPrompt {
id: string;
name: string;
description?: string;
use_case: string;
system_message?: string;
user_template: string;
status?: "draft" | "active" | "archived" | "deprecated";
provider?: string;
model?: string;
temperature?: number;
max_tokens?: number;
variables?: PromptVariable[];
tags?: string[];
version?: number;
created_at?: string;
updated_at?: string;
}
export interface KnowledgeCollection {
id: string;
name: string;
description?: string;
status?: string;
created_at?: string;
updated_at?: string;
}
export interface KnowledgeSearchItem {
id: string;
score: number;
content?: string;
metadata?: Record<string, unknown>;
}
export interface AICostSummary {
summary: {
total_completions: number;
total_tokens: number;
total_cost: number;
avg_latency_ms: number;
};
by_provider: Array<{
provider: string;
completions: number;
tokens: number;
cost: number;
}>;
by_model: Array<{
model: string;
provider: string;
completions: number;
tokens: number;
cost: number;
}>;
}