diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 4fd7286..da376fe 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -128,13 +128,13 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [ requiredPermission: { resource: "ai" }, }, { - label: "Tenant Config", - path: "/tenant/ai/config", + label: "Prompt Management", + path: "/tenant/ai/prompts", requiredPermission: { resource: "ai" }, }, { - label: "Prompts", - path: "/tenant/ai/prompts", + label: "Tenant Config", + path: "/tenant/ai/config", requiredPermission: { resource: "ai" }, }, { diff --git a/src/pages/tenant/CompletionDetail.tsx b/src/pages/tenant/CompletionDetail.tsx index 5f3eced..848156a 100644 --- a/src/pages/tenant/CompletionDetail.tsx +++ b/src/pages/tenant/CompletionDetail.tsx @@ -87,7 +87,7 @@ const CompletionDetail = (): ReactElement => {

Summary

-

ID: {row.id}

+ {/*

ID: {row.id}

*/}
{row.status || "unknown"} @@ -96,14 +96,14 @@ const CompletionDetail = (): ReactElement => {
- - - + {/* */} + + {/* */} - + {/* */} - + {/* */} { - + {/* - - + */} + {row.error_message && ( {row.error_message}} /> )} @@ -153,7 +153,7 @@ const CompletionDetail = (): ReactElement => { - {row.metadata != null && ( + {/* {row.metadata != null && (

Metadata

@@ -164,7 +164,7 @@ const CompletionDetail = (): ReactElement => { : JSON.stringify(row.metadata, null, 2)}
- )} + )} */}
navigate("/tenant/ai/completions")}> diff --git a/src/pages/tenant/PromptCreate.tsx b/src/pages/tenant/PromptCreate.tsx new file mode 100644 index 0000000..8e53af9 --- /dev/null +++ b/src/pages/tenant/PromptCreate.tsx @@ -0,0 +1,289 @@ +import { useEffect, useMemo, useState, type ReactElement } from "react"; +import { useNavigate } from "react-router-dom"; +import { Layout } from "@/components/layout/Layout"; +import { FormField, FormSelect, FormTextArea, PrimaryButton, SecondaryButton } from "@/components/shared"; +import { Plus, Trash2 } from "lucide-react"; +import { aiService } from "@/services/ai-service"; +import { showToast } from "@/utils/toast"; + +type PromptVariable = { + name: string; + type: "string" | "number" | "boolean" | "array"; + required: boolean; + default: string; +}; + +const PromptCreate = (): ReactElement => { + const navigate = useNavigate(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [providers, setProviders] = useState>([]); + const [models, setModels] = useState>([]); + + const [form, setForm] = useState({ + name: "", + description: "", + use_case: "", + system_message: "", + user_template: "", + provider: "", + model: "", + temperature: "0.3", + max_tokens: "2048", + tags: "", + is_default: false, + }); + const [variables, setVariables] = useState([ + { name: "focus_areas", type: "string", required: false, default: "regulatory obligations, deadlines, action items" }, + ]); + + useEffect(() => { + const loadMeta = async (): Promise => { + try { + const [providerData, modelData] = await Promise.all([aiService.getProviders(), aiService.getModels()]); + setProviders(providerData.map((p) => ({ value: p.name, label: p.displayName || p.name }))); + setModels(modelData.map((m) => ({ value: m.id, label: `${m.id} (${m.provider})` }))); + } catch (err: unknown) { + showToast.error( + (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || + "Failed to load provider metadata", + ); + } + }; + void loadMeta(); + }, []); + + const parsedTags = useMemo( + () => + form.tags + .split(",") + .map((tag) => tag.trim()) + .filter(Boolean), + [form.tags], + ); + + const addVariable = (): void => { + setVariables((prev) => [...prev, { name: "", type: "string", required: false, default: "" }]); + }; + + const updateVariable = (index: number, patch: Partial): void => { + setVariables((prev) => prev.map((item, idx) => (idx === index ? { ...item, ...patch } : item))); + }; + + const removeVariable = (index: number): void => { + setVariables((prev) => prev.filter((_, idx) => idx !== index)); + }; + + const handleSubmit = async (): Promise => { + if (!form.name.trim() || !form.use_case.trim() || !form.user_template.trim()) { + showToast.error("Name, use case, and user template are required"); + return; + } + + const sanitizedVariables = variables + .filter((v) => v.name.trim()) + .map((v) => ({ + name: v.name.trim(), + type: v.type, + required: v.required, + ...(v.default.trim() ? { default: v.default.trim() } : {}), + })); + + setIsSubmitting(true); + try { + await aiService.createPrompt({ + name: form.name.trim(), + description: form.description.trim() || undefined, + use_case: form.use_case.trim(), + system_message: form.system_message.trim() || undefined, + user_template: form.user_template, + model: form.model || undefined, + provider: form.provider || undefined, + temperature: Number(form.temperature), + max_tokens: Number(form.max_tokens), + variables: sanitizedVariables, + tags: parsedTags, + is_default: form.is_default, + }); + showToast.success("Prompt created successfully"); + navigate("/tenant/ai/prompts"); + } catch (err: unknown) { + showToast.error( + (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || + "Failed to create prompt", + ); + } finally { + setIsSubmitting(false); + } + }; + + return ( + +
+
+
+ setForm((prev) => ({ ...prev, name: e.target.value }))} + placeholder="e.g., Code Review Assitant" + helperText="Max 255 characters. Must be unique per tenant and version" + /> + setForm((prev) => ({ ...prev, use_case: e.target.value }))} + placeholder="doc_summary" + /> +
+ + setForm((prev) => ({ ...prev, description: e.target.value }))} + placeholder="Describe what this prompt template is used for..." + helperText="Optional template summary to explain the purpose and expected usage." + /> + + setForm((prev) => ({ ...prev, system_message: e.target.value }))} + placeholder="You are a regulatory compliance expert..." + /> + + setForm((prev) => ({ ...prev, user_template: e.target.value }))} + placeholder={"Summarize the following document:\n\n{{document_text}}\n\nFocus on: {{focus_areas}}"} + /> + +
+ setForm((prev) => ({ ...prev, provider: value }))} + options={providers} + placeholder="Select provider" + /> + setForm((prev) => ({ ...prev, model: value }))} + options={models} + placeholder="Select model" + /> + setForm((prev) => ({ ...prev, temperature: e.target.value }))} + /> + setForm((prev) => ({ ...prev, max_tokens: e.target.value }))} + /> +
+ + setForm((prev) => ({ ...prev, tags: e.target.value }))} + placeholder="compliance, document, summary" + /> + + + +
+
+

Variables

+ +
+ + {variables.length === 0 &&

No variables added.

} + + {variables.map((variable, index) => ( +
+ updateVariable(index, { name: e.target.value })} + placeholder="focus_areas" + /> + updateVariable(index, { type: value as PromptVariable["type"] })} + options={[ + { value: "string", label: "string" }, + { value: "number", label: "number" }, + { value: "boolean", label: "boolean" }, + { value: "array", label: "array" }, + ]} + /> + updateVariable(index, { default: e.target.value })} + placeholder="optional default" + /> + + +
+ ))} +
+ +
+ navigate("/tenant/ai/prompts")}>Cancel + + {isSubmitting ? "Creating..." : "Create Prompt"} + +
+
+
+
+ ); +}; + +export default PromptCreate; diff --git a/src/pages/tenant/PromptManagement.tsx b/src/pages/tenant/PromptManagement.tsx new file mode 100644 index 0000000..ce9399c --- /dev/null +++ b/src/pages/tenant/PromptManagement.tsx @@ -0,0 +1,198 @@ +import { useEffect, useMemo, useState, type ReactElement } from "react"; +import { useNavigate } from "react-router-dom"; +import { Plus, Search } from "lucide-react"; +import { Layout } from "@/components/layout/Layout"; +import { DataTable, type Column, Pagination, PrimaryButton, StatusBadge } from "@/components/shared"; +import { aiService } from "@/services/ai-service"; +import type { AIPrompt } from "@/types/ai"; +import { showToast } from "@/utils/toast"; +import { formatDate } from "@/utils/format-date"; +import { useAppTheme } from "@/hooks/useAppTheme"; + +const PromptManagement = (): ReactElement => { + const { primaryColor } = useAppTheme(); + const navigate = useNavigate(); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [prompts, setPrompts] = useState([]); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [page, setPage] = useState(1); + const [limit, setLimit] = useState(10); + const [pagination, setPagination] = useState({ + page: 1, + limit: 10, + total: 0, + totalPages: 1, + }); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 500); + return () => clearTimeout(timer); + }, [search]); + + const loadPrompts = async (): Promise => { + setIsLoading(true); + setError(null); + try { + const result = await aiService.listPrompts({ + page, + limit, + search: debouncedSearch || undefined, + }); + setPrompts(result.data || []); + setPagination({ + page: result.pagination?.page || page, + limit: result.pagination?.limit || limit, + total: result.pagination?.total || 0, + totalPages: result.pagination?.totalPages || 1, + }); + } catch (err: unknown) { + const message = + (err as { response?: { data?: { error?: { message?: string } } } })?.response?.data?.error?.message || + "Failed to load prompts"; + setError(message); + showToast.error(message); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + void loadPrompts(); + }, [page, limit, debouncedSearch]); + + const columns: Column[] = useMemo( + () => [ + { + key: "name", + label: "Name & Description", + render: (row) => ( +
+

{row.name}

+

{row.description || "No description"}

+
+ ), + }, + { + key: "use_case", + label: "Use Case", + render: (row) => { + const useCase = (row as any).useCase || row.use_case; + if (!useCase) return ; + return ( + + {String(useCase)} + + ); + }, + }, + { + key: "status", + label: "Status", + render: (row) => ( + + {(row as any).status || "draft"} + + ), + }, + { + key: "tags", + label: "Tags", + render: (row) => { + const tags = ((row as any).tags || []) as string[]; + + if (!tags.length) { + return ; + } + + return ( +
+ {tags.map((tag, index) => ( + + {tag} + + ))} +
+ ); + }, + }, + { + key: "Created At", + label: "Created At", + render: (row) => ( + {formatDate((row as any).createdAt || "")} + ), + }, + ], + [], + ); + + return ( + navigate("/tenant/ai/prompts/create")} className="flex items-center gap-2"> + + Create Prompt +
+ ), + }} + > +
+
+
+ + setSearch(e.target.value)} + placeholder="Search prompts by name or use case..." + className="w-full pl-9 pr-4 py-2 border border-[rgba(0,0,0,0.08)] rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-[#112868]/10 focus:border-[#112868]" + /> +
+
+ + item.id} + isLoading={isLoading} + error={error} + emptyMessage="No prompts found" + /> + + {pagination.total > 0 && ( +
+ { + setLimit(newLimit); + setPage(1); + }} + /> +
+ )} +
+ + ); +}; + +export default PromptManagement; diff --git a/src/routes/tenant-admin-routes.tsx b/src/routes/tenant-admin-routes.tsx index ccc5d52..1b961db 100644 --- a/src/routes/tenant-admin-routes.tsx +++ b/src/routes/tenant-admin-routes.tsx @@ -40,6 +40,8 @@ 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")); +const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement")); +const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate")); // Loading fallback component const RouteLoader = (): ReactElement => ( @@ -192,7 +194,11 @@ export const tenantAdminRoutes: RouteConfig[] = [ }, { path: "/tenant/ai/prompts", - element: , + element: , + }, + { + path: "/tenant/ai/prompts/create", + element: , }, { path: "/tenant/ai/knowledge", diff --git a/src/services/ai-service.ts b/src/services/ai-service.ts index 97d24bf..7d88a51 100644 --- a/src/services/ai-service.ts +++ b/src/services/ai-service.ts @@ -134,6 +134,7 @@ class AIService { max_tokens?: number; variables?: Array<{ name: string; type?: "string" | "number" | "boolean" | "array"; required?: boolean }>; tags?: string[]; + is_default?: boolean; }): Promise { const response = await apiClient.post("/ai/prompts", payload); return unwrap(response);