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,
|
ChevronRight,
|
||||||
Bell,
|
Bell,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
|
Bot,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -116,6 +117,34 @@ const tenantAdminPlatformMenu: MenuItem[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const tenantAdminPlatformServiceMenu: 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,
|
icon: Paperclip,
|
||||||
label: "File Attachments",
|
label: "File Attachments",
|
||||||
|
|||||||
@ -1,4 +1,6 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react';
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import { ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
export interface Column<T> {
|
export interface Column<T> {
|
||||||
key: string;
|
key: string;
|
||||||
@ -16,6 +18,13 @@ interface DataTableProps<T> {
|
|||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
error?: string | null;
|
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,>({
|
export const DataTable = <T,>({
|
||||||
@ -26,7 +35,17 @@ export const DataTable = <T,>({
|
|||||||
emptyMessage = 'No data found',
|
emptyMessage = 'No data found',
|
||||||
isLoading = false,
|
isLoading = false,
|
||||||
error = null,
|
error = null,
|
||||||
|
expandableRows = false,
|
||||||
|
isRowExpanded,
|
||||||
|
onRowExpandToggle,
|
||||||
|
renderExpandedRow,
|
||||||
|
onRowClick,
|
||||||
|
showExpandColumn = true,
|
||||||
|
expandedColSpan,
|
||||||
}: DataTableProps<T>): ReactElement => {
|
}: DataTableProps<T>): ReactElement => {
|
||||||
|
const canExpand = expandableRows && !!onRowExpandToggle && !!isRowExpanded && !!renderExpandedRow;
|
||||||
|
const desktopColSpan = expandedColSpan || columns.length + (canExpand && showExpandColumn ? 1 : 0);
|
||||||
|
|
||||||
// Loading State
|
// Loading State
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@ -55,6 +74,9 @@ export const DataTable = <T,>({
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
<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) => {
|
{columns.map((column) => {
|
||||||
const alignClass =
|
const alignClass =
|
||||||
column.align === 'right'
|
column.align === 'right'
|
||||||
@ -75,7 +97,7 @@ export const DataTable = <T,>({
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<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}
|
{emptyMessage}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -99,6 +121,9 @@ export const DataTable = <T,>({
|
|||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-[#f5f7fa] border-b border-[rgba(0,0,0,0.08)]">
|
<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) => {
|
{columns.map((column) => {
|
||||||
const alignClass =
|
const alignClass =
|
||||||
column.align === 'right'
|
column.align === 'right'
|
||||||
@ -118,11 +143,30 @@ export const DataTable = <T,>({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.map((item) => (
|
{data.map((item) => {
|
||||||
|
const rowId = keyExtractor(item);
|
||||||
|
const expanded = canExpand ? !!isRowExpanded(item) : false;
|
||||||
|
return (
|
||||||
|
<Fragment key={rowId}>
|
||||||
<tr
|
<tr
|
||||||
key={keyExtractor(item)}
|
|
||||||
className="border-b border-[rgba(0,0,0,0.08)] hover:bg-gray-50 transition-colors"
|
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) => {
|
{columns.map((column) => {
|
||||||
const alignClass =
|
const alignClass =
|
||||||
column.align === 'right'
|
column.align === 'right'
|
||||||
@ -137,7 +181,16 @@ export const DataTable = <T,>({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@ -147,8 +200,22 @@ export const DataTable = <T,>({
|
|||||||
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
|
<div className="md:hidden divide-y divide-[rgba(0,0,0,0.08)]">
|
||||||
{mobileCardRenderer
|
{mobileCardRenderer
|
||||||
? data.map((item) => <div key={keyExtractor(item)}>{mobileCardRenderer(item)}</div>)
|
? 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">
|
<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) => (
|
{columns.map((column) => (
|
||||||
<div key={column.key} className="mb-2.5 sm:mb-3 last:mb-0">
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{canExpand && expanded && renderExpandedRow && (
|
||||||
|
<div className="mt-3">
|
||||||
|
{renderExpandedRow(item)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -113,7 +113,7 @@ const AuditLogResourceTypes = (): ReactElement => {
|
|||||||
|
|
||||||
const fetchModules = async () => {
|
const fetchModules = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await moduleService.getAll(1, 100);
|
const response = await moduleService.getDropdown();
|
||||||
if (response.success) {
|
if (response.success) {
|
||||||
setModules(response.data);
|
setModules(response.data);
|
||||||
}
|
}
|
||||||
@ -325,6 +325,7 @@ const AuditLogResourceTypes = (): ReactElement => {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
placeholder="All Modules"
|
placeholder="All Modules"
|
||||||
|
isSearchable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Sort Filter */}
|
{/* Sort Filter */}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { Layout } from '@/components/layout/Layout';
|
import { Layout } from '@/components/layout/Layout';
|
||||||
import {
|
import {
|
||||||
@ -9,11 +9,13 @@ import {
|
|||||||
StatusBadge,
|
StatusBadge,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} 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 { auditLogService } from '@/services/audit-log-service';
|
||||||
import { tenantService } from '@/services/tenant-service';
|
import { tenantService } from '@/services/tenant-service';
|
||||||
import type { AuditLog } from '@/types/audit-log';
|
import type { AuditLog } from '@/types/audit-log';
|
||||||
import type { Tenant } from '@/types/tenant';
|
import type { Tenant } from '@/types/tenant';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useAppTheme } from '@/hooks/useAppTheme';
|
||||||
|
|
||||||
// Helper function to format date
|
// Helper function to format date
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
@ -58,8 +60,10 @@ const getStatusColor = (status: number | null): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const AuditLogs = (): ReactElement => {
|
const AuditLogs = (): ReactElement => {
|
||||||
|
const { primaryColor } = useAppTheme();
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
|
||||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||||
|
const [modules, setModules] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
|
const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@ -86,11 +90,18 @@ const AuditLogs = (): ReactElement => {
|
|||||||
const [methodFilter, setMethodFilter] = useState<string | null>(null);
|
const [methodFilter, setMethodFilter] = useState<string | null>(null);
|
||||||
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
const [actionFilter, setActionFilter] = useState<string | null>(null);
|
||||||
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
|
const [resourceTypeFilter, setResourceTypeFilter] = useState<string | null>(null);
|
||||||
|
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
|
||||||
const [startDate, setStartDate] = useState<string>('');
|
const [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
const [orderBy, setOrderBy] = useState<string[] | null>(null);
|
||||||
const [search, setSearch] = useState<string>('');
|
const [search, setSearch] = useState<string>('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = 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
|
// View modal
|
||||||
const [viewModalOpen, setViewModalOpen] = useState<boolean>(false);
|
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();
|
fetchTenants();
|
||||||
fetchResourceTypes();
|
fetchResourceTypes();
|
||||||
|
fetchModules();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasExtraFilters) {
|
||||||
|
setShowMoreFilters(true);
|
||||||
|
}
|
||||||
|
}, [hasExtraFilters]);
|
||||||
|
|
||||||
const fetchAuditLogs = async (): Promise<void> => {
|
const fetchAuditLogs = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@ -140,6 +169,7 @@ const AuditLogs = (): ReactElement => {
|
|||||||
method: methodFilter,
|
method: methodFilter,
|
||||||
action: actionFilter,
|
action: actionFilter,
|
||||||
resource_type: resourceTypeFilter,
|
resource_type: resourceTypeFilter,
|
||||||
|
module_id: moduleFilter,
|
||||||
startDate: startDate || null,
|
startDate: startDate || null,
|
||||||
endDate: endDate || null,
|
endDate: endDate || null,
|
||||||
search: debouncedSearch || null,
|
search: debouncedSearch || null,
|
||||||
@ -171,7 +201,7 @@ const AuditLogs = (): ReactElement => {
|
|||||||
// Fetch audit logs on mount and when pagination/filters change
|
// Fetch audit logs on mount and when pagination/filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAuditLogs();
|
fetchAuditLogs();
|
||||||
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, debouncedSearch]);
|
}, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, moduleFilter, startDate, endDate, orderBy, debouncedSearch]);
|
||||||
|
|
||||||
// Handle Export
|
// Handle Export
|
||||||
const handleExport = async (): Promise<void> => {
|
const handleExport = async (): Promise<void> => {
|
||||||
@ -414,22 +444,30 @@ const AuditLogs = (): ReactElement => {
|
|||||||
isSearchable
|
isSearchable
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Method Filter */}
|
<button
|
||||||
<FilterDropdown
|
type="button"
|
||||||
label="Method"
|
onClick={() => setShowMoreFilters((open) => !open)}
|
||||||
options={[
|
className={cn(
|
||||||
{ value: 'GET', label: 'GET' },
|
'inline-flex items-center gap-1.5 h-10 px-3 rounded-md text-sm font-medium border bg-white transition-colors',
|
||||||
{ value: 'POST', label: 'POST' },
|
showMoreFilters || hasExtraFilters
|
||||||
{ value: 'PUT', label: 'PUT' },
|
? 'border-[rgba(8,76,200,0.35)] text-[#0f1724]'
|
||||||
{ value: 'DELETE', label: 'DELETE' },
|
: 'border-[rgba(0,0,0,0.08)] text-[#475569] hover:border-[#084cc8]/30'
|
||||||
]}
|
)}
|
||||||
value={methodFilter}
|
style={
|
||||||
onChange={(value) => {
|
showMoreFilters || hasExtraFilters
|
||||||
setMethodFilter(value as string | null);
|
? { borderColor: `${primaryColor}55`, color: primaryColor }
|
||||||
setCurrentPage(1);
|
: undefined
|
||||||
}}
|
}
|
||||||
placeholder="All Methods"
|
>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
@ -445,7 +483,55 @@ const AuditLogs = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-xs font-medium text-[#475569]">Start Date:</span>
|
<span className="text-xs font-medium text-[#475569]">Start Date:</span>
|
||||||
<input
|
<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"
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
<FilterDropdown
|
{(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || moduleFilter || methodFilter || search || orderBy) && (
|
||||||
label="Sort"
|
<div className="flex justify-end pt-1">
|
||||||
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) && (
|
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setStartDate('');
|
setStartDate('');
|
||||||
@ -495,15 +569,19 @@ const AuditLogs = (): ReactElement => {
|
|||||||
setTenantFilter(null);
|
setTenantFilter(null);
|
||||||
setActionFilter(null);
|
setActionFilter(null);
|
||||||
setResourceTypeFilter(null);
|
setResourceTypeFilter(null);
|
||||||
|
setModuleFilter(null);
|
||||||
|
setMethodFilter(null);
|
||||||
|
setOrderBy(null);
|
||||||
setSearch('');
|
setSearch('');
|
||||||
|
setShowMoreFilters(false);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}}
|
}}
|
||||||
className="text-xs text-[#ef4444] hover:underline ml-auto"
|
className="text-xs text-[#ef4444] hover:underline"
|
||||||
>
|
>
|
||||||
Reset All Filters
|
Reset All Filters
|
||||||
</button>
|
</button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import {
|
|||||||
FormField,
|
FormField,
|
||||||
FormTextArea,
|
FormTextArea,
|
||||||
FormSelect,
|
FormSelect,
|
||||||
|
FilterDropdown,
|
||||||
Pagination,
|
Pagination,
|
||||||
type Column,
|
type Column,
|
||||||
} from '@/components/shared';
|
} from '@/components/shared';
|
||||||
@ -117,6 +118,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
const [totalItems, setTotalItems] = useState(0);
|
const [totalItems, setTotalItems] = useState(0);
|
||||||
const [totalPages, setTotalPages] = useState(0);
|
const [totalPages, setTotalPages] = useState(0);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
const [moduleFilter, setModuleFilter] = useState<string | null>(null);
|
||||||
|
|
||||||
// Category Modal
|
// Category Modal
|
||||||
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
const [categoryModalOpen, setCategoryModalOpen] = useState<boolean>(false);
|
||||||
@ -170,7 +172,8 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
const res = await notificationService.getCategories({
|
const res = await notificationService.getCategories({
|
||||||
limit,
|
limit,
|
||||||
offset: (currentPage - 1) * limit,
|
offset: (currentPage - 1) * limit,
|
||||||
search
|
search,
|
||||||
|
module_id: moduleFilter || undefined
|
||||||
});
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
setCategories(res.data);
|
setCategories(res.data);
|
||||||
@ -186,7 +189,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
|
|
||||||
const fetchModules = async () => {
|
const fetchModules = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await moduleService.getAll(1, 100);
|
const res = await moduleService.getDropdown();
|
||||||
if (res.success) setModules(res.data);
|
if (res.success) setModules(res.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch modules', err);
|
console.error('Failed to fetch modules', err);
|
||||||
@ -196,7 +199,7 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCategories();
|
fetchCategories();
|
||||||
fetchModules();
|
fetchModules();
|
||||||
}, [currentPage, limit, search]);
|
}, [currentPage, limit, search, moduleFilter]);
|
||||||
|
|
||||||
const fetchCodes = async (category: any, page: number = 1) => {
|
const fetchCodes = async (category: any, page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
@ -344,6 +347,18 @@ const NotificationMaster = (): ReactElement => {
|
|||||||
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<PrimaryButton onClick={() => {
|
<PrimaryButton onClick={() => {
|
||||||
setEditingCategory(null);
|
setEditingCategory(null);
|
||||||
|
|||||||
@ -154,7 +154,7 @@ const NotificationTemplateMaster = (): ReactElement => {
|
|||||||
module_id: selectedModule || undefined,
|
module_id: selectedModule || undefined,
|
||||||
}),
|
}),
|
||||||
notificationService.getCategories({ limit: 100 }),
|
notificationService.getCategories({ limit: 100 }),
|
||||||
moduleService.getAll(1, 100),
|
moduleService.getDropdown(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (tRes.success) {
|
if (tRes.success) {
|
||||||
|
|||||||
@ -301,7 +301,7 @@ const TenantDetails = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
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" />
|
<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 StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard"));
|
||||||
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings"));
|
||||||
const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails"));
|
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
|
// Loading fallback component
|
||||||
const RouteLoader = (): ReactElement => (
|
const RouteLoader = (): ReactElement => (
|
||||||
@ -166,4 +170,32 @@ export const tenantAdminRoutes: RouteConfig[] = [
|
|||||||
path: "/tenant/settings/failed-emails",
|
path: "/tenant/settings/failed-emails",
|
||||||
element: <LazyRoute component={FailedEmails} />,
|
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;
|
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> => {
|
getAllResourceTypes: async (page = 1, limit = 50, filters: any = {}): Promise<any> => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
params.append('page', String(page));
|
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';
|
import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse } from '@/types/module';
|
||||||
|
|
||||||
export const moduleService = {
|
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 (
|
getAll: async (
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
limit: number = 20,
|
limit: number = 20,
|
||||||
|
|||||||
@ -61,6 +61,12 @@ export interface DeleteTenantResponse {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TenantUserDropdownItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
role: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export const tenantService = {
|
export const tenantService = {
|
||||||
getAll: async (
|
getAll: async (
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
@ -98,4 +104,15 @@ export const tenantService = {
|
|||||||
const response = await apiClient.delete<DeleteTenantResponse>(`/tenants/${id}`);
|
const response = await apiClient.delete<DeleteTenantResponse>(`/tenants/${id}`);
|
||||||
return response.data;
|
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