diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 004cc3d..4fd7286 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -16,6 +16,7 @@ import { ChevronRight, Bell, Paperclip, + Bot, } from "lucide-react"; import { cn } from "@/lib/utils"; @@ -116,6 +117,34 @@ const tenantAdminPlatformMenu: MenuItem[] = [ ]; const tenantAdminPlatformServiceMenu: MenuItem[] = [ + { + icon: Bot, + label: "AI Services", + isGroup: true, + children: [ + { + label: "Completion History", + path: "/tenant/ai/completions", + requiredPermission: { resource: "ai" }, + }, + { + label: "Tenant Config", + path: "/tenant/ai/config", + requiredPermission: { resource: "ai" }, + }, + { + label: "Prompts", + path: "/tenant/ai/prompts", + requiredPermission: { resource: "ai" }, + }, + { + label: "Knowledge (RAG)", + path: "/tenant/ai/knowledge", + requiredPermission: { resource: "ai" }, + }, + ], + requiredPermission: { resource: "ai" }, + }, { icon: Paperclip, label: "File Attachments", diff --git a/src/components/shared/DataTable.tsx b/src/components/shared/DataTable.tsx index 8b4e07f..7a19e50 100644 --- a/src/components/shared/DataTable.tsx +++ b/src/components/shared/DataTable.tsx @@ -1,4 +1,6 @@ import type { ReactElement, ReactNode } from 'react'; +import { Fragment } from 'react'; +import { ChevronDown } from 'lucide-react'; export interface Column { key: string; @@ -16,6 +18,13 @@ interface DataTableProps { emptyMessage?: string; isLoading?: boolean; error?: string | null; + expandableRows?: boolean; + isRowExpanded?: (item: T) => boolean; + onRowExpandToggle?: (item: T) => void; + renderExpandedRow?: (item: T) => ReactNode; + onRowClick?: (item: T) => void; + showExpandColumn?: boolean; + expandedColSpan?: number; } export const DataTable = ({ @@ -26,7 +35,17 @@ export const DataTable = ({ emptyMessage = 'No data found', isLoading = false, error = null, + expandableRows = false, + isRowExpanded, + onRowExpandToggle, + renderExpandedRow, + onRowClick, + showExpandColumn = true, + expandedColSpan, }: DataTableProps): ReactElement => { + const canExpand = expandableRows && !!onRowExpandToggle && !!isRowExpanded && !!renderExpandedRow; + const desktopColSpan = expandedColSpan || columns.length + (canExpand && showExpandColumn ? 1 : 0); + // Loading State if (isLoading) { return ( @@ -55,6 +74,9 @@ export const DataTable = ({ + {canExpand && showExpandColumn && ( + - @@ -99,6 +121,9 @@ export const DataTable = ({
+ )} {columns.map((column) => { const alignClass = column.align === 'right' @@ -75,7 +97,7 @@ export const DataTable = ({
+ {emptyMessage}
+ {canExpand && showExpandColumn && ( + - {data.map((item) => ( - - {columns.map((column) => { - const alignClass = - column.align === 'right' - ? 'text-right' - : column.align === 'center' - ? 'text-center' - : 'text-left'; - return ( - - ); - })} - - ))} + {data.map((item) => { + const rowId = keyExtractor(item); + const expanded = canExpand ? !!isRowExpanded(item) : false; + return ( + + onRowClick(item) : undefined} + > + {canExpand && showExpandColumn && ( + + )} + {columns.map((column) => { + const alignClass = + column.align === 'right' + ? 'text-right' + : column.align === 'center' + ? 'text-center' + : 'text-left'; + return ( + + ); + })} + + {canExpand && expanded && ( + + + + )} + + ); + })}
+ )} {columns.map((column) => { const alignClass = column.align === 'right' @@ -118,26 +143,54 @@ export const DataTable = ({
- {column.render ? column.render(item) : String((item as any)[column.key])} -
+ + + {column.render ? column.render(item) : String((item as any)[column.key])} +
+ {renderExpandedRow(item)} +
@@ -147,20 +200,40 @@ export const DataTable = ({
{mobileCardRenderer ? data.map((item) =>
{mobileCardRenderer(item)}
) - : data.map((item) => ( -
- {columns.map((column) => ( -
- - {column.mobileLabel || column.label}: - -
- {column.render ? column.render(item) : String((item as any)[column.key])} + : data.map((item) => { + const expanded = canExpand ? !!isRowExpanded(item) : false; + return ( +
+ {canExpand && ( +
+
-
- ))} -
- ))} + )} + {columns.map((column) => ( +
+ + {column.mobileLabel || column.label}: + +
+ {column.render ? column.render(item) : String((item as any)[column.key])} +
+
+ ))} + {canExpand && expanded && renderExpandedRow && ( +
+ {renderExpandedRow(item)} +
+ )} +
+ ); + })}
); diff --git a/src/pages/superadmin/AuditLogResourceTypes.tsx b/src/pages/superadmin/AuditLogResourceTypes.tsx index b621f5c..8f364af 100644 --- a/src/pages/superadmin/AuditLogResourceTypes.tsx +++ b/src/pages/superadmin/AuditLogResourceTypes.tsx @@ -113,7 +113,7 @@ const AuditLogResourceTypes = (): ReactElement => { const fetchModules = async () => { try { - const response = await moduleService.getAll(1, 100); + const response = await moduleService.getDropdown(); if (response.success) { setModules(response.data); } @@ -325,6 +325,7 @@ const AuditLogResourceTypes = (): ReactElement => { setCurrentPage(1); }} placeholder="All Modules" + isSearchable /> {/* Sort Filter */} diff --git a/src/pages/superadmin/AuditLogs.tsx b/src/pages/superadmin/AuditLogs.tsx index 866ad85..762e7d9 100644 --- a/src/pages/superadmin/AuditLogs.tsx +++ b/src/pages/superadmin/AuditLogs.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useMemo } from 'react'; import type { ReactElement } from 'react'; import { Layout } from '@/components/layout/Layout'; import { @@ -9,11 +9,13 @@ import { StatusBadge, type Column, } from '@/components/shared'; -import { Download, ArrowUpDown, Search } from 'lucide-react'; +import { Download, ArrowUpDown, Search, ChevronDown, SlidersHorizontal } from 'lucide-react'; import { auditLogService } from '@/services/audit-log-service'; import { tenantService } from '@/services/tenant-service'; import type { AuditLog } from '@/types/audit-log'; import type { Tenant } from '@/types/tenant'; +import { cn } from '@/lib/utils'; +import { useAppTheme } from '@/hooks/useAppTheme'; // Helper function to format date const formatDate = (dateString: string): string => { @@ -58,8 +60,10 @@ const getStatusColor = (status: number | null): string => { }; const AuditLogs = (): ReactElement => { + const { primaryColor } = useAppTheme(); const [auditLogs, setAuditLogs] = useState([]); const [tenants, setTenants] = useState([]); + const [modules, setModules] = useState>([]); const [resourceTypes, setResourceTypes] = useState<{ value: string; label: string }[]>([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -86,11 +90,18 @@ const AuditLogs = (): ReactElement => { const [methodFilter, setMethodFilter] = useState(null); const [actionFilter, setActionFilter] = useState(null); const [resourceTypeFilter, setResourceTypeFilter] = useState(null); + const [moduleFilter, setModuleFilter] = useState(null); const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [orderBy, setOrderBy] = useState(null); const [search, setSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); + const [showMoreFilters, setShowMoreFilters] = useState(false); + + const hasExtraFilters = useMemo( + () => Boolean(moduleFilter || methodFilter || startDate || endDate || orderBy), + [moduleFilter, methodFilter, startDate, endDate, orderBy] + ); // View modal const [viewModalOpen, setViewModalOpen] = useState(false); @@ -124,10 +135,28 @@ const AuditLogs = (): ReactElement => { } }; + const fetchModules = async () => { + try { + const response = await auditLogService.getModulesDropdown(); + if (response.success) { + setModules(response.data || []); + } + } catch (err) { + console.error('Failed to load modules:', err); + } + }; + fetchTenants(); fetchResourceTypes(); + fetchModules(); }, []); + useEffect(() => { + if (hasExtraFilters) { + setShowMoreFilters(true); + } + }, [hasExtraFilters]); + const fetchAuditLogs = async (): Promise => { try { setIsLoading(true); @@ -140,6 +169,7 @@ const AuditLogs = (): ReactElement => { method: methodFilter, action: actionFilter, resource_type: resourceTypeFilter, + module_id: moduleFilter, startDate: startDate || null, endDate: endDate || null, search: debouncedSearch || null, @@ -171,7 +201,7 @@ const AuditLogs = (): ReactElement => { // Fetch audit logs on mount and when pagination/filters change useEffect(() => { fetchAuditLogs(); - }, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, startDate, endDate, orderBy, debouncedSearch]); + }, [currentPage, limit, tenantFilter, methodFilter, actionFilter, resourceTypeFilter, moduleFilter, startDate, endDate, orderBy, debouncedSearch]); // Handle Export const handleExport = async (): Promise => { @@ -414,22 +444,30 @@ const AuditLogs = (): ReactElement => { isSearchable /> - {/* Method Filter */} - { - setMethodFilter(value as string | null); - setCurrentPage(1); - }} - placeholder="All Methods" - /> +
{/* Actions */} @@ -445,65 +483,105 @@ const AuditLogs = (): ReactElement => { -
-
- Start Date: - { - setStartDate(e.target.value); - setCurrentPage(1); - }} - className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" - /> -
-
- End Date: - { - setEndDate(e.target.value); - setCurrentPage(1); - }} - className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" - /> -
+ {showMoreFilters && ( +
+
+ ({ value: m.id, label: m.name }))} + value={moduleFilter} + onChange={(value) => { + setModuleFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Modules" + isSearchable + /> - { - setOrderBy(value as string[] | null); - setCurrentPage(1); - }} - placeholder="Newest" - showIcon - icon={} - /> + { + setMethodFilter(value as string | null); + setCurrentPage(1); + }} + placeholder="All Methods" + /> - {(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || search) && ( -
+ +
+
+ Start Date: + { + setStartDate(e.target.value); + setCurrentPage(1); + }} + className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" + /> +
+
+ End Date: + { + setEndDate(e.target.value); + setCurrentPage(1); + }} + className="text-xs px-2 py-1.5 border border-[rgba(0,0,0,0.08)] rounded focus:outline-none focus:ring-1 focus:ring-[#112868]/20" + /> +
+
+
+ )} + + {(startDate || endDate || tenantFilter || actionFilter || resourceTypeFilter || moduleFilter || methodFilter || search || orderBy) && ( +
+ - )} -
+
+ )} {/* Table */} diff --git a/src/pages/superadmin/NotificationMaster.tsx b/src/pages/superadmin/NotificationMaster.tsx index 9e9eec7..a2116c2 100644 --- a/src/pages/superadmin/NotificationMaster.tsx +++ b/src/pages/superadmin/NotificationMaster.tsx @@ -10,6 +10,7 @@ import { FormField, FormTextArea, FormSelect, + FilterDropdown, Pagination, type Column, } from '@/components/shared'; @@ -117,6 +118,7 @@ const NotificationMaster = (): ReactElement => { const [totalItems, setTotalItems] = useState(0); const [totalPages, setTotalPages] = useState(0); const [search, setSearch] = useState(''); + const [moduleFilter, setModuleFilter] = useState(null); // Category Modal const [categoryModalOpen, setCategoryModalOpen] = useState(false); @@ -170,7 +172,8 @@ const NotificationMaster = (): ReactElement => { const res = await notificationService.getCategories({ limit, offset: (currentPage - 1) * limit, - search + search, + module_id: moduleFilter || undefined }); if (res.success) { setCategories(res.data); @@ -186,7 +189,7 @@ const NotificationMaster = (): ReactElement => { const fetchModules = async () => { try { - const res = await moduleService.getAll(1, 100); + const res = await moduleService.getDropdown(); if (res.success) setModules(res.data); } catch (err) { console.error('Failed to fetch modules', err); @@ -196,7 +199,7 @@ const NotificationMaster = (): ReactElement => { useEffect(() => { fetchCategories(); fetchModules(); - }, [currentPage, limit, search]); + }, [currentPage, limit, search, moduleFilter]); const fetchCodes = async (category: any, page: number = 1) => { try { @@ -344,6 +347,18 @@ const NotificationMaster = (): ReactElement => { onChange={(e) => { setSearch(e.target.value); setCurrentPage(1); }} /> + + { + setModuleFilter(val as string | null); + setCurrentPage(1); + }} + options={modules.map((m) => ({ value: m.id, label: m.name }))} + placeholder="All Modules" + isSearchable + /> { setEditingCategory(null); diff --git a/src/pages/superadmin/NotificationTemplateMaster.tsx b/src/pages/superadmin/NotificationTemplateMaster.tsx index 07e33b1..97a77cd 100644 --- a/src/pages/superadmin/NotificationTemplateMaster.tsx +++ b/src/pages/superadmin/NotificationTemplateMaster.tsx @@ -154,7 +154,7 @@ const NotificationTemplateMaster = (): ReactElement => { module_id: selectedModule || undefined, }), notificationService.getCategories({ limit: 100 }), - moduleService.getAll(1, 100), + moduleService.getDropdown(), ]); if (tRes.success) { diff --git a/src/pages/superadmin/TenantDetails.tsx b/src/pages/superadmin/TenantDetails.tsx index 4922d20..cab27c7 100644 --- a/src/pages/superadmin/TenantDetails.tsx +++ b/src/pages/superadmin/TenantDetails.tsx @@ -301,7 +301,7 @@ const TenantDetails = (): ReactElement => { + + + ), + }, + ]; + + const promptColumns: Column[] = [ + { 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) => ( + + {row.status || "draft"} + + ), + }, + { + key: "activate", + label: "Action", + align: "right", + render: (row) => ( + + ), + }, + ]; + + const collectionColumns: Column[] = [ + { key: "name", label: "Collection" }, + { key: "description", label: "Description", render: (row) => row.description || "-" }, + { + key: "status", + label: "Status", + render: (row) => ( + + {row.status || "active"} + + ), + }, + ]; + + return ( + +
+
+
+
+
+ +
+
+

{tabMeta[activeTab].label}

+

{tabMeta[activeTab].subtitle}

+
+
+
+ {(Object.keys(tabPath) as TabKey[]).map((tab) => { + const Icon = tabMeta[tab].icon; + return ( + + + {tabMeta[tab].label} + + ); + })} +
+
+
+ + {activeTab === "gateway" && ( + <> +
+ + + + +
+ +
+ +
+ setCompletionForm((prev) => ({ ...prev, provider: value }))} + options={providerOptions} + placeholder="Auto" + /> + setCompletionForm((prev) => ({ ...prev, model: value }))} + options={modelOptions} + placeholder="Provider default" + /> +
+ setCompletionForm((prev) => ({ ...prev, message: e.target.value }))} + /> +
+ setCompletionForm((prev) => ({ ...prev, temperature: e.target.value }))} + /> + setCompletionForm((prev) => ({ ...prev, max_tokens: e.target.value }))} + /> +
+
+ + {creatingCompletion ? "Running..." : "Run Playground"} + + void loadGatewayData()}>Refresh Data +
+
+ +
+ +
+ {playgroundMeta.provider || "Provider: -"} + {playgroundMeta.model || "Model: -"} + + Latency: {playgroundMeta.latency ? `${playgroundMeta.latency} ms` : "-"} + +
+
+

{playgroundResult || "No output yet."}

+
+
+ + +
+ setCostFilter({ group_by: value as "day" | "week" | "month" })} + options={[ + { value: "day", label: "Daily" }, + { value: "week", label: "Weekly" }, + { value: "month", label: "Monthly" }, + ]} + /> + + + +
+ `${item.provider}-${item.completions}`} + emptyMessage="No cost data available" + isLoading={isLoading} + /> +
+
+
+ + +
+ item.name} + emptyMessage="No providers available" + isLoading={isLoading} + /> + item.id} + emptyMessage="No completions yet" + isLoading={isLoading} + /> +
+
+ + )} + + {activeTab === "config" && ( +
+ + setConfigForm((prev) => ({ ...prev, provider: value }))} + options={providerOptions} + /> + setConfigForm((prev) => ({ ...prev, config_type: value }))} + options={[ + { value: "direct", label: "Direct Endpoint" }, + { value: "azure", label: "Azure" }, + ]} + /> + setConfigForm((prev) => ({ ...prev, api_key: e.target.value }))} + required + /> + setConfigForm((prev) => ({ ...prev, endpoint: e.target.value }))} + placeholder="http://host.docker.internal:11434/v1" + /> + setConfigForm((prev) => ({ ...prev, default_model: e.target.value }))} + /> + setConfigForm((prev) => ({ ...prev, custom_models: e.target.value }))} + placeholder="mistral-small3.2:24b,llama3.2" + /> +
+ + {savingConfig ? "Saving..." : "Save Configuration"} + + void loadConfigData()}>Reload +
+
+ +
+ + item.id} + emptyMessage="No tenant provider configs found" + isLoading={isLoading} + /> + + +
+ Example endpoint: + + http://host.docker.internal:11434/v1 + +
+
+
+
+ )} + + {activeTab === "prompts" && ( +
+
+ + setPromptForm((prev) => ({ ...prev, name: e.target.value }))} + required + /> + setPromptForm((prev) => ({ ...prev, use_case: e.target.value }))} + required + /> + setPromptForm((prev) => ({ ...prev, system_message: e.target.value }))} + /> + setPromptForm((prev) => ({ ...prev, user_template: e.target.value }))} + /> +
+ setPromptForm((prev) => ({ ...prev, provider: value }))} + options={providerOptions} + placeholder="Auto" + /> + setPromptForm((prev) => ({ ...prev, model: value }))} + options={modelOptions} + placeholder="Provider default" + /> +
+
+ setPromptForm((prev) => ({ ...prev, temperature: e.target.value }))} + /> + setPromptForm((prev) => ({ ...prev, max_tokens: e.target.value }))} + /> +
+
+ + {creatingPrompt ? "Creating..." : "Create Prompt"} + + void loadPromptData()}>Refresh +
+
+ + + ({ + value: p.id, + label: `${p.name} (${p.status || "draft"})`, + }))} + /> + setExecuteVariables(e.target.value)} + /> +
+ Run Prompt Test +
+
+ {executeMeta.provider || "Provider: -"} + {executeMeta.model || "Model: -"} +
+
+

{executeResult || "No test output yet."}

+
+
+
+ + + item.id} + emptyMessage="No prompts found" + isLoading={isLoading} + /> + +
+ )} + + {activeTab === "knowledge" && ( +
+
+ + setCollectionForm((prev) => ({ ...prev, name: e.target.value }))} + required + /> + setCollectionForm((prev) => ({ ...prev, description: e.target.value }))} + /> + + {creatingCollection ? "Creating..." : "Create Collection"} + + + + + +
+ + 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" + /> +
+ + {uploadingDoc ? "Uploading..." : "Upload & Ingest"} + +
+ + + + setSearchQuery(e.target.value)} + /> +
+ Search Context + void loadKnowledgeData()}>Refresh +
+
+
+ + +
+

{searchResult || "No context result yet."}

+
+
+ + + item.id} + emptyMessage="No collections available" + isLoading={isLoading} + /> + +
+ )} +
+
+ ); +}; + +export default AIGateway; diff --git a/src/pages/tenant/CompletionCreate.tsx b/src/pages/tenant/CompletionCreate.tsx new file mode 100644 index 0000000..9c9bbd2 --- /dev/null +++ b/src/pages/tenant/CompletionCreate.tsx @@ -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([]); + const [models, setModels] = useState>([]); + const [isLoading, setIsLoading] = useState(false); + const [isSending, setIsSending] = useState(false); + const [isPlaygroundMode, setIsPlaygroundMode] = useState(true); + + const [form, setForm] = useState({ + user: "", + provider: "gemini", + model: "", + temperature: "0.7", + max_tokens: "1024", + }); + const [lastSentUserMessage, setLastSentUserMessage] = useState(""); + const [displayedResponse, setDisplayedResponse] = useState(""); + const typingIntervalRef = useRef | 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 => { + 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 => { + 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 ( + +
+
+
+
+ {!lastSentUserMessage && !isSending && !displayedResponse ? ( +
+

+ Start by typing a message and click Send. +

+
+ ) : ( + <> + {lastSentUserMessage && ( + <> +
+

You

+ +
+
+ {lastSentUserMessage} +
+ + )} + + {isSending && ( + <> +
+ +

Calling LLM

+
+
+ ... +
+ + )} + + {(displayedResponse || responseData.content) && !isSending && ( + <> +
+ +

Assistant

+
+
+

+ {displayedResponse || responseData.content} +

+
+ + )} + + )} + + {(displayedResponse || responseData.content) && ( +
+ {responseData.provider || "Provider -"} + {responseData.model || "Model -"} + {responseData.latency_ms ? `${responseData.latency_ms} ms` : "Latency -"} + + {responseData.fallbackUsed ? "Fallback Used" : "No Fallback"} + +
+ )} +
+
+ +
+
+ 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" + /> + + + {isSending ? "Sending..." : "Send"} + +
+
+
+ + +
+
+ ); +}; + +export default CompletionCreate; diff --git a/src/pages/tenant/CompletionDetail.tsx b/src/pages/tenant/CompletionDetail.tsx new file mode 100644 index 0000000..5f3eced --- /dev/null +++ b/src/pages/tenant/CompletionDetail.tsx @@ -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 => ( +
+
{label}
+
{value}
+
+); + +const CompletionDetail = (): ReactElement => { + const { completionId } = useParams<{ completionId: string }>(); + const navigate = useNavigate(); + const [row, setRow] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + if (!completionId) return; + let cancelled = false; + const load = async (): Promise => { + 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 ( + navigate("/tenant/ai/completions")}> + + Back to list + + ), + }} + > +
+ {isLoading && ( +

Loading completion…

+ )} + {error && !isLoading && ( +
+

{error}

+ navigate("/tenant/ai/completions")}> + Return to list + +
+ )} + {row && !isLoading && ( + <> +
+
+
+

Summary

+

ID: {row.id}

+
+ + {row.status || "unknown"} + +
+
+
+ + + + + + + + + + + + + + + + + + + {row.error_message && ( + {row.error_message}} /> + )} + {row.error_code && } +
+
+
+ + {row.system_message && ( +
+
+

System message

+
+
+                  {row.system_message}
+                
+
+ )} + +
+
+

Prompt

+
+
+                {row.prompt || "—"}
+              
+
+ +
+
+

Response

+
+
+                {row.response || row.content || "—"}
+              
+
+ + {row.metadata != null && ( +
+
+

Metadata

+
+
+                  {typeof row.metadata === "string"
+                    ? row.metadata
+                    : JSON.stringify(row.metadata, null, 2)}
+                
+
+ )} + +
+ navigate("/tenant/ai/completions")}> + Back to list + + navigate("/tenant/ai/completions/create")}> + New completion + +
+ + )} +
+
+ ); +}; + +export default CompletionDetail; diff --git a/src/pages/tenant/CompletionHistory.tsx b/src/pages/tenant/CompletionHistory.tsx new file mode 100644 index 0000000..bc30294 --- /dev/null +++ b/src/pages/tenant/CompletionHistory.tsx @@ -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(false); + const [isMetaLoading, setIsMetaLoading] = useState(true); + + const [providers, setProviders] = useState([]); + const [models, setModels] = useState>([]); + const [modules, setModules] = useState([]); + const [tenantUsers, setTenantUsers] = useState([]); + + const [completions, setCompletions] = useState([]); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(10); + const [pagination, setPagination] = useState({ + page: 1, + limit: 10, + total: 0, + totalPages: 1, + }); + + const [showMoreFilters, setShowMoreFilters] = useState(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 => { + 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 => { + 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 ( +
+ {/*

+ IDs — + Module: {row.module_id || "—"} + {" · "} + User: {row.user_id || "—"} +

+

+ Correlation — + {row.correlation_id || "—"} +

*/} +

+ Tokens / cost / latency — + {`${total} tokens · USD ${Number(row.cost ?? 0).toFixed(6)} · ${row.latency_ms ?? 0} ms`} +

+ {row.use_case && ( +

+ Use case — + {row.use_case} +

+ )} + {(row.error_message || row.error_code) && ( +

+ Error — + {[row.error_code, row.error_message].filter(Boolean).join(" · ")} +

+ )} +
+ Response preview +

{preview || "—"}

+
+ +
+ ); + }; + + const columns: Column[] = [ + { + key: "date", + label: "Date", + render: (row) => {formatListDate(row.created_at)}, + }, + { + key: "module_name", + label: "Module", + render: (row) => {row.module_name || "Platform"}, + }, + { + key: "user_name", + label: "User", + render: (row) => {row.user_name || "—"}, + }, + { key: "provider", label: "Provider", render: (row) => row.provider || "—" }, + { + key: "model", + label: "Model", + render: (row) => {row.model || "—"}, + }, + { + key: "status", + label: "Status", + render: (row) => ( + + {row.status || "unknown"} + + ), + }, + { + key: "view", + label: "View", + align: "right", + render: (row) => ( + + ), + }, + ]; + + return ( + navigate("/tenant/ai/completions/create")}> + Create Completion +
+ ), + }} + > +
+
+
+

+ Completion List +

+ +
+
+
+ { + setPage(1); + setFilters((prev) => ({ ...prev, provider: value as string | null })); + }} + /> + { + setPage(1); + setFilters((prev) => ({ ...prev, model: value as string | null })); + }} + /> + { + setPage(1); + setFilters((prev) => ({ ...prev, status: value as string | null })); + }} + /> + + +
+ + +
+ + {showMoreFilters && ( +
+
+ { + setPage(1); + setFilters((prev) => ({ ...prev, moduleId: value as string | null })); + }} + /> + { + setPage(1); + setFilters((prev) => ({ ...prev, userId: value as string | null })); + }} + /> +
+
+
+ From: + { + 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" + /> +
+
+ To: + { + 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" + /> +
+
+
+ )} +
+ + {isMetaLoading && ( +

Loading filter options…

+ )} +
+ + 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 && ( +
+ { + setLimit(newLimit); + setPage(1); + }} + limitOptions={[ + { value: "5", label: "5 per page" }, + { value: "10", label: "10 per page" }, + { value: "20", label: "20 per page" }, + ]} + /> +
+ )} +
+
+ + ); +}; + +export default CompletionHistory; diff --git a/src/pages/tenant/CompletionPlayground.tsx b/src/pages/tenant/CompletionPlayground.tsx new file mode 100644 index 0000000..f7f82f6 --- /dev/null +++ b/src/pages/tenant/CompletionPlayground.tsx @@ -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([]); +// const [isLoading, setIsLoading] = useState(false); +// const [isRunning, setIsRunning] = useState(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 => { +// 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 => { +// 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 ( +// +//
+//
+//

+// Playground Request +//

+//

+// Uses /ai/playground for fast testing without history persistence. +//

+ +//
+// setForm((prev) => ({ ...prev, provider: value }))} +// /> +// setForm((prev) => ({ ...prev, model: e.target.value }))} +// placeholder="e.g. gemini-2.0-flash" +// /> +//
+ +//
+// setForm((prev) => ({ ...prev, max_tokens: e.target.value }))} +// /> +// setForm((prev) => ({ ...prev, temperature: e.target.value }))} +// /> +//
+ +// setForm((prev) => ({ ...prev, user: e.target.value }))} +// /> + +//
+// +// {isRunning ? "Running..." : "Run Playground"} +// +// void loadProviders()}>Reload Providers +//
+//
+ +//
+//

+// Playground Response +//

+//
+// Provider: {result.provider || "-"} +// Model: {result.model || "-"} +// Latency: {result.latency_ms || 0} ms +// +// Tokens: {result.prompt_tokens}/{result.completion_tokens}/{result.total_tokens} +// +// +// Fallback: {result.fallbackUsed ? "Used" : "No"} +// +//
+ +//
+//

+// {result.content || "No response yet."} +//

+//
+//
+//
+//
+// ); +// }; + +// export default CompletionPlayground; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index 65de163..ccc5d52 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -36,6 +36,10 @@ const FileView = lazy(() => import("@/pages/tenant/FileView")); const StorageDashboard = lazy(() => import("@/pages/tenant/StorageDashboard")); const SmtpSettings = lazy(() => import("@/pages/tenant/SmtpSettings")); const FailedEmails = lazy(() => import("@/pages/tenant/FailedEmails")); +const AIGateway = lazy(() => import("@/pages/tenant/AIGateway")); +const CompletionHistory = lazy(() => import("@/pages/tenant/CompletionHistory")); +const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate")); +const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -166,4 +170,32 @@ export const tenantAdminRoutes: RouteConfig[] = [ path: "/tenant/settings/failed-emails", element: , }, + { + path: "/tenant/ai", + element: , + }, + { + path: "/tenant/ai/completions", + element: , + }, + { + path: "/tenant/ai/completions/create", + element: , + }, + { + path: "/tenant/ai/completions/:completionId", + element: , + }, + { + path: "/tenant/ai/config", + element: , + }, + { + path: "/tenant/ai/prompts", + element: , + }, + { + path: "/tenant/ai/knowledge", + element: , + }, ]; diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts new file mode 100644 index 0000000..97d24bf --- /dev/null +++ b/src/services/ai-service.ts @@ -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 = (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 { + const response = await apiClient.get("/ai/providers"); + return unwrap(response); + } + + async getProviderHealth(provider: string): Promise { + const response = await apiClient.get(`/ai/providers/${encodeURIComponent(provider)}/health`); + return unwrap(response); + } + + async getGatewayHealth(): Promise { + const response = await apiClient.get("/ai/health"); + return unwrap(response); + } + + async getModels(): Promise> { + const response = await apiClient.get("/ai/models"); + return unwrap>(response); + } + + async createCompletion(payload: { + messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; + provider?: string; + model?: string; + temperature?: number; + max_tokens?: number; + }): Promise { + const response = await apiClient.post("/ai/completions", payload); + return unwrap(response); + } + + async getCompletion(id: string): Promise { + const response = await apiClient.get(`/ai/completions/${encodeURIComponent(id)}`); + return unwrap(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 { + 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(response); + } + + async playground(payload: { + messages: Array<{ role: "system" | "user" | "assistant"; content: string }>; + provider?: string; + model?: string; + temperature?: number; + max_tokens?: number; + }): Promise { + const response = await apiClient.post("/ai/playground", payload); + return unwrap(response); + } + + async getCostSummary(params: { + group_by?: "day" | "week" | "month"; + start_date?: string; + end_date?: string; + } = {}): Promise { + const response = await apiClient.get("/ai/costs", { params }); + return unwrap(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 { + const response = await apiClient.post("/ai/config", payload); + return unwrap(response); + } + + async listConfigs(): Promise { + const response = await apiClient.get("/ai/config"); + return unwrap(response); + } + + async testConfig(provider: string): Promise { + const response = await apiClient.post(`/ai/config/${encodeURIComponent(provider)}/test`, {}); + return unwrap(response); + } + + async deleteConfig(provider: string): Promise { + 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 { + const response = await apiClient.post("/ai/prompts", payload); + return unwrap(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 { + const response = await apiClient.patch(`/ai/prompts/${id}/status`, { status }); + return unwrap(response); + } + + async executePrompt( + id: string, + payload: { variables?: Record; provider?: string; model?: string; temperature?: number; max_tokens?: number }, + ): Promise { + const response = await apiClient.post(`/ai/prompts/${id}/execute`, payload); + return unwrap(response); + } + + async testPrompt( + id: string, + payload: { variables?: Record; provider?: string; model?: string; temperature?: number; max_tokens?: number }, + ): Promise { + const response = await apiClient.post(`/ai/prompts/${id}/test`, payload); + return unwrap(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 }): Promise { + const response = await apiClient.post("/ai/knowledge/collections", payload); + return unwrap(response); + } + + async uploadKnowledgeDocument(payload: { collectionId: string; file: File; provider?: string }): Promise { + 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 { + const response = await apiClient.post("/ai/knowledge/search", payload); + return unwrap(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(); diff --git a/src/services/audit-log-service.ts b/src/services/audit-log-service.ts index 0eb8b61..b408001 100644 --- a/src/services/audit-log-service.ts +++ b/src/services/audit-log-service.ts @@ -68,6 +68,11 @@ export const auditLogService = { return response.data; }, + getModulesDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => { + const response = await apiClient.get('/modules/dropdown'); + return response.data; + }, + getAllResourceTypes: async (page = 1, limit = 50, filters: any = {}): Promise => { const params = new URLSearchParams(); params.append('page', String(page)); diff --git a/src/services/module-service.ts b/src/services/module-service.ts index c55f118..8c2f141 100644 --- a/src/services/module-service.ts +++ b/src/services/module-service.ts @@ -2,6 +2,10 @@ import apiClient from './api-client'; import type { ModulesResponse, GetModuleResponse, CreateModuleRequest, CreateModuleResponse, LaunchModuleResponse, MyModulesResponse } from '@/types/module'; export const moduleService = { + getDropdown: async (): Promise<{ success: boolean; data: Array<{ id: string; name: string }> }> => { + const response = await apiClient.get('/modules/dropdown'); + return response.data; + }, getAll: async ( page: number = 1, limit: number = 20, diff --git a/src/services/tenant-service.ts b/src/services/tenant-service.ts index 1062176..b44a561 100644 --- a/src/services/tenant-service.ts +++ b/src/services/tenant-service.ts @@ -61,6 +61,12 @@ export interface DeleteTenantResponse { message?: string; } +export interface TenantUserDropdownItem { + id: string; + name: string; + role: string | null; +} + export const tenantService = { getAll: async ( page: number = 1, @@ -98,4 +104,15 @@ export const tenantService = { const response = await apiClient.delete(`/tenants/${id}`); return response.data; }, + + /** Active users in the current JWT tenant (for filter dropdowns: name + role). */ + getCurrentTenantUsersDropdown: async (): Promise => { + const response = await apiClient.get<{ success: boolean; data: TenantUserDropdownItem[] }>( + "/tenants/current/users/dropdown", + ); + if (response?.data?.data !== undefined) { + return response.data.data; + } + return []; + }, }; diff --git a/src/types/ai.ts b/src/types/ai.ts new file mode 100644 index 0000000..9d9730b --- /dev/null +++ b/src/types/ai.ts @@ -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; +} + +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; + 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; +} + +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; + }>; +}