@@ -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);