feat: add AI services menu to tenant admin platform and enhance DataTable with expandable rows and filters
This commit is contained in:
parent
edb631df36
commit
93dd820fe2
@ -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",
|
||||
|
||||
@ -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,11 +143,30 @@ export const DataTable = <T,>({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((item) => (
|
||||
{data.map((item) => {
|
||||
const rowId = keyExtractor(item);
|
||||
const expanded = canExpand ? !!isRowExpanded(item) : false;
|
||||
return (
|
||||
<Fragment key={rowId}>
|
||||
<tr
|
||||
key={keyExtractor(item)}
|
||||
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'
|
||||
@ -137,7 +181,16 @@ export const DataTable = <T,>({
|
||||
);
|
||||
})}
|
||||
</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,8 +200,22 @@ 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) => (
|
||||
: 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>
|
||||
)}
|
||||
{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">
|
||||
@ -159,8 +226,14 @@ export const DataTable = <T,>({
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{canExpand && expanded && renderExpandedRow && (
|
||||
<div className="mt-3">
|
||||
{renderExpandedRow(item)}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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 */}
|
||||
|
||||
@ -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,7 +483,55 @@ 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">
|
||||
{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="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"
|
||||
/>
|
||||
|
||||
<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
|
||||
@ -470,24 +556,12 @@ const AuditLogs = (): ReactElement => {
|
||||
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>
|
||||
)}
|
||||
|
||||
<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]" />}
|
||||
/>
|
||||
|
||||
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || search) && (
|
||||
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || moduleFilter || methodFilter || search || orderBy) && (
|
||||
<div className="flex justify-end pt-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
setStartDate('');
|
||||
@ -495,15 +569,19 @@ const AuditLogs = (): ReactElement => {
|
||||
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>
|
||||
|
||||
{/* Table */}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -154,7 +154,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
||||
module_id: selectedModule || undefined,
|
||||
}),
|
||||
notificationService.getCategories({ limit: 100 }),
|
||||
moduleService.getAll(1, 100),
|
||||
moduleService.getDropdown(),
|
||||
]);
|
||||
|
||||
if (tRes.success) {
|
||||
|
||||
@ -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" />
|
||||
|
||||
952
src/pages/tenant/AIGateway.tsx
Normal file
952
src/pages/tenant/AIGateway.tsx
Normal 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;
|
||||
342
src/pages/tenant/CompletionCreate.tsx
Normal file
342
src/pages/tenant/CompletionCreate.tsx
Normal 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;
|
||||
184
src/pages/tenant/CompletionDetail.tsx
Normal file
184
src/pages/tenant/CompletionDetail.tsx
Normal 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;
|
||||
493
src/pages/tenant/CompletionHistory.tsx
Normal file
493
src/pages/tenant/CompletionHistory.tsx
Normal 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;
|
||||
180
src/pages/tenant/CompletionPlayground.tsx
Normal file
180
src/pages/tenant/CompletionPlayground.tsx
Normal 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;
|
||||
@ -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
224
src/services/ai-service.ts
Normal 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();
|
||||
@ -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));
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
158
src/types/ai.ts
Normal 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;
|
||||
}>;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user