feat: rename sidebar menu items for clarity, update CompletionDetail component to hide unused fields, and add new routes for Prompt Management

This commit is contained in:
Yashwin 2026-04-20 17:59:24 +05:30
parent 93dd820fe2
commit 92ea75ce77
6 changed files with 510 additions and 16 deletions

View File

@ -128,13 +128,13 @@ const tenantAdminPlatformServiceMenu: MenuItem[] = [
requiredPermission: { resource: "ai" }, requiredPermission: { resource: "ai" },
}, },
{ {
label: "Tenant Config", label: "Prompt Management",
path: "/tenant/ai/config", path: "/tenant/ai/prompts",
requiredPermission: { resource: "ai" }, requiredPermission: { resource: "ai" },
}, },
{ {
label: "Prompts", label: "Tenant Config",
path: "/tenant/ai/prompts", path: "/tenant/ai/config",
requiredPermission: { resource: "ai" }, requiredPermission: { resource: "ai" },
}, },
{ {

View File

@ -87,7 +87,7 @@ const CompletionDetail = (): ReactElement => {
<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 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> <div>
<h2 className="text-base font-semibold text-[#0f1724]">Summary</h2> <h2 className="text-base font-semibold text-[#0f1724]">Summary</h2>
<p className="text-xs text-[#64748b] mt-0.5">ID: {row.id}</p> {/* <p className="text-xs text-[#64748b] mt-0.5">ID: {row.id}</p> */}
</div> </div>
<StatusBadge variant={row.status === "completed" ? "success" : "failure"}> <StatusBadge variant={row.status === "completed" ? "success" : "failure"}>
{row.status || "unknown"} {row.status || "unknown"}
@ -96,14 +96,14 @@ const CompletionDetail = (): ReactElement => {
<div className="px-4 md:px-5 py-3"> <div className="px-4 md:px-5 py-3">
<dl> <dl>
<DetailRow label="Created" value={formatWhen(row.created_at)} /> <DetailRow label="Created" value={formatWhen(row.created_at)} />
<DetailRow label="Completed" value={formatWhen(row.completed_at)} /> {/* <DetailRow label="Completed" value={formatWhen(row.completed_at)} /> */}
<DetailRow label="Module" value={row.module_name || ""} /> <DetailRow label="Module" value={row.module_name || "Platform"} />
<DetailRow label="Module ID" value={row.module_id || "—"} /> {/* <DetailRow label="Module ID" value={row.module_id || "—"} /> */}
<DetailRow label="User" value={row.user_name || "—"} /> <DetailRow label="User" value={row.user_name || "—"} />
<DetailRow label="User ID" value={row.user_id || "—"} /> {/* <DetailRow label="User ID" value={row.user_id || "—"} /> */}
<DetailRow label="Provider / model" value={`${row.provider} / ${row.model}`} /> <DetailRow label="Provider / model" value={`${row.provider} / ${row.model}`} />
<DetailRow label="Use case" value={row.use_case || "—"} /> <DetailRow label="Use case" value={row.use_case || "—"} />
<DetailRow label="Correlation" value={row.correlation_id || "—"} /> {/* <DetailRow label="Correlation" value={row.correlation_id || "—"} /> */}
<DetailRow <DetailRow
label="Tokens" 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})`} 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})`}
@ -112,10 +112,10 @@ const CompletionDetail = (): ReactElement => {
<DetailRow label="Latency" value={`${row.latency_ms ?? 0} ms`} /> <DetailRow label="Latency" value={`${row.latency_ms ?? 0} ms`} />
<DetailRow label="Temperature" value={row.temperature != null ? String(row.temperature) : "—"} /> <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="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="Top p" value={row.top_p != null ? String(row.top_p) : "—"} />
<DetailRow label="Cached" value={row.cached != null ? (row.cached ? "Yes" : "No") : "—"} /> <DetailRow label="Cached" value={row.cached != null ? (row.cached ? "Yes" : "No") : "—"} />
<DetailRow label="Streaming" value={row.streaming != null ? (row.streaming ? "Yes" : "No") : "—"} /> <DetailRow label="Streaming" value={row.streaming != null ? (row.streaming ? "Yes" : "No") : "—"} /> */}
<DetailRow label="Fallback provider" value={row.fallback_provider || ""} /> <DetailRow label="Fallback provider" value={row.fallback_provider || "Not used"} />
{row.error_message && ( {row.error_message && (
<DetailRow label="Error" value={<span className="text-red-600">{row.error_message}</span>} /> <DetailRow label="Error" value={<span className="text-red-600">{row.error_message}</span>} />
)} )}
@ -153,7 +153,7 @@ const CompletionDetail = (): ReactElement => {
</pre> </pre>
</section> </section>
{row.metadata != null && ( {/* {row.metadata != null && (
<section className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden"> <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)]"> <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> <h3 className="text-sm font-semibold text-[#0f1724]">Metadata</h3>
@ -164,7 +164,7 @@ const CompletionDetail = (): ReactElement => {
: JSON.stringify(row.metadata, null, 2)} : JSON.stringify(row.metadata, null, 2)}
</pre> </pre>
</section> </section>
)} )} */}
<div className="flex gap-2"> <div className="flex gap-2">
<PrimaryButton type="button" onClick={() => navigate("/tenant/ai/completions")}> <PrimaryButton type="button" onClick={() => navigate("/tenant/ai/completions")}>

View File

@ -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<boolean>(false);
const [providers, setProviders] = useState<Array<{ value: string; label: string }>>([]);
const [models, setModels] = useState<Array<{ value: string; label: string }>>([]);
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<PromptVariable[]>([
{ name: "focus_areas", type: "string", required: false, default: "regulatory obligations, deadlines, action items" },
]);
useEffect(() => {
const loadMeta = async (): Promise<void> => {
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<PromptVariable>): 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<void> => {
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 (
<Layout
currentPage="Create Prompt"
pageHeader={{
title: "Create Prompt",
description: "Create a reusable prompt template for AI workflows.",
}}
>
<div className="max-w-5xl mx-auto bg-white border border-[rgba(0,0,0,0.08)] rounded-lg">
<div className="p-5 md:p-6 space-y-5">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<FormField
label="Template Name"
required
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="e.g., Code Review Assitant"
helperText="Max 255 characters. Must be unique per tenant and version"
/>
<FormField
label="Use Case"
required
value={form.use_case}
onChange={(e) => setForm((prev) => ({ ...prev, use_case: e.target.value }))}
placeholder="doc_summary"
/>
</div>
<FormTextArea
label="Description"
value={form.description}
onChange={(e) => 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."
/>
<FormTextArea
label="System Message"
value={form.system_message}
onChange={(e) => setForm((prev) => ({ ...prev, system_message: e.target.value }))}
placeholder="You are a regulatory compliance expert..."
/>
<FormTextArea
label="User Template"
required
value={form.user_template}
onChange={(e) => setForm((prev) => ({ ...prev, user_template: e.target.value }))}
placeholder={"Summarize the following document:\n\n{{document_text}}\n\nFocus on: {{focus_areas}}"}
/>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<FormSelect
label="Provider"
value={form.provider}
onValueChange={(value) => setForm((prev) => ({ ...prev, provider: value }))}
options={providers}
placeholder="Select provider"
/>
<FormSelect
label="Model"
value={form.model}
onValueChange={(value) => setForm((prev) => ({ ...prev, model: value }))}
options={models}
placeholder="Select model"
/>
<FormField
label="Temperature"
type="number"
value={form.temperature}
onChange={(e) => setForm((prev) => ({ ...prev, temperature: e.target.value }))}
/>
<FormField
label="Max Tokens"
type="number"
value={form.max_tokens}
onChange={(e) => setForm((prev) => ({ ...prev, max_tokens: e.target.value }))}
/>
</div>
<FormField
label="Tags (comma separated)"
value={form.tags}
onChange={(e) => setForm((prev) => ({ ...prev, tags: e.target.value }))}
placeholder="compliance, document, summary"
/>
<label className="inline-flex items-center gap-2 text-sm text-[#334155]">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setForm((prev) => ({ ...prev, is_default: e.target.checked }))}
className="w-4 h-4"
/>
Set as default prompt
</label>
<div className="border border-[rgba(0,0,0,0.08)] rounded-md p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-[#0f1724]">Variables</h3>
<button
type="button"
onClick={addVariable}
className="inline-flex items-center gap-1 text-xs font-medium text-[#112868]"
>
<Plus className="w-3.5 h-3.5" />
Add Variable
</button>
</div>
{variables.length === 0 && <p className="text-xs text-[#94a3b8]">No variables added.</p>}
{variables.map((variable, index) => (
<div key={index} className="grid grid-cols-1 md:grid-cols-5 gap-3 items-end">
<FormField
label="Name"
value={variable.name}
onChange={(e) => updateVariable(index, { name: e.target.value })}
placeholder="focus_areas"
/>
<FormSelect
label="Type"
value={variable.type}
onValueChange={(value) => updateVariable(index, { type: value as PromptVariable["type"] })}
options={[
{ value: "string", label: "string" },
{ value: "number", label: "number" },
{ value: "boolean", label: "boolean" },
{ value: "array", label: "array" },
]}
/>
<FormField
label="Default"
value={variable.default}
onChange={(e) => updateVariable(index, { default: e.target.value })}
placeholder="optional default"
/>
<label className="inline-flex items-center gap-2 text-sm text-[#334155] h-10">
<input
type="checkbox"
checked={variable.required}
onChange={(e) => updateVariable(index, { required: e.target.checked })}
className="w-4 h-4"
/>
Required
</label>
<button
type="button"
onClick={() => removeVariable(index)}
className="h-10 inline-flex items-center justify-center gap-1 text-xs font-medium text-[#ef4444] border border-[rgba(239,68,68,0.2)] rounded-md"
>
<Trash2 className="w-3.5 h-3.5" />
Remove
</button>
</div>
))}
</div>
<div className="flex items-center justify-end gap-2 pt-2 border-t border-[rgba(0,0,0,0.06)]">
<SecondaryButton onClick={() => navigate("/tenant/ai/prompts")}>Cancel</SecondaryButton>
<PrimaryButton onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create Prompt"}
</PrimaryButton>
</div>
</div>
</div>
</Layout>
);
};
export default PromptCreate;

View File

@ -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<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [prompts, setPrompts] = useState<AIPrompt[]>([]);
const [search, setSearch] = useState<string>("");
const [debouncedSearch, setDebouncedSearch] = useState<string>("");
const [page, setPage] = useState<number>(1);
const [limit, setLimit] = useState<number>(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<void> => {
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<AIPrompt>[] = useMemo(
() => [
{
key: "name",
label: "Name & Description",
render: (row) => (
<div className="min-w-0">
<p className="text-sm font-semibold text-[#0f1724] truncate">{row.name}</p>
<p className="text-xs text-[#64748b] truncate">{row.description || "No description"}</p>
</div>
),
},
{
key: "use_case",
label: "Use Case",
render: (row) => {
const useCase = (row as any).useCase || row.use_case;
if (!useCase) return <span className="text-sm text-[#94a3b8]"></span>;
return (
<span className="inline-flex items-center rounded-full border border-[#084cc8] bg-white px-3 py-1 text-xs font-medium text-[#084cc8]">
{String(useCase)}
</span>
);
},
},
{
key: "status",
label: "Status",
render: (row) => (
<StatusBadge variant={((row as any).status || "draft") === "active" ? "success" : "process"}>
{(row as any).status || "draft"}
</StatusBadge>
),
},
{
key: "tags",
label: "Tags",
render: (row) => {
const tags = ((row as any).tags || []) as string[];
if (!tags.length) {
return <span className="text-xs text-[#94a3b8]"></span>;
}
return (
<div className="flex flex-wrap gap-1">
{tags.map((tag, index) => (
<span
key={index}
className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-medium"
style={{
backgroundColor: `${primaryColor}1A`,
color: primaryColor,
}}
>
{tag}
</span>
))}
</div>
);
},
},
{
key: "Created At",
label: "Created At",
render: (row) => (
<span className="text-xs text-[#334155]">{formatDate((row as any).createdAt || "")}</span>
),
},
],
[],
);
return (
<Layout
currentPage="Prompt Management"
pageHeader={{
title: "Prompt Management",
description: "Manage reusable AI prompts and versioned templates.",
action: (
<PrimaryButton onClick={() => navigate("/tenant/ai/prompts/create")} className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Create Prompt
</PrimaryButton>
),
}}
>
<div className="bg-white border border-[rgba(0,0,0,0.08)] rounded-lg overflow-hidden">
<div className="p-4 border-b border-[rgba(0,0,0,0.08)]">
<div className="relative w-full md:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-[#94a3b8]" />
<input
type="text"
value={search}
onChange={(e) => 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]"
/>
</div>
</div>
<DataTable
data={prompts}
columns={columns}
keyExtractor={(item) => item.id}
isLoading={isLoading}
error={error}
emptyMessage="No prompts found"
/>
{pagination.total > 0 && (
<div className="border-t border-[rgba(0,0,0,0.08)] px-4 py-3">
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
totalItems={pagination.total}
limit={limit}
onPageChange={setPage}
onLimitChange={(newLimit) => {
setLimit(newLimit);
setPage(1);
}}
/>
</div>
)}
</div>
</Layout>
);
};
export default PromptManagement;

View File

@ -40,6 +40,8 @@ const AIGateway = lazy(() => import("@/pages/tenant/AIGateway"));
const CompletionHistory = lazy(() => import("@/pages/tenant/CompletionHistory")); const CompletionHistory = lazy(() => import("@/pages/tenant/CompletionHistory"));
const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate")); const CompletionCreate = lazy(() => import("@/pages/tenant/CompletionCreate"));
const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail")); const CompletionDetail = lazy(() => import("@/pages/tenant/CompletionDetail"));
const PromptManagement = lazy(() => import("@/pages/tenant/PromptManagement"));
const PromptCreate = lazy(() => import("@/pages/tenant/PromptCreate"));
// Loading fallback component // Loading fallback component
const RouteLoader = (): ReactElement => ( const RouteLoader = (): ReactElement => (
@ -192,7 +194,11 @@ export const tenantAdminRoutes: RouteConfig[] = [
}, },
{ {
path: "/tenant/ai/prompts", path: "/tenant/ai/prompts",
element: <LazyRoute component={AIGateway} />, element: <LazyRoute component={PromptManagement} />,
},
{
path: "/tenant/ai/prompts/create",
element: <LazyRoute component={PromptCreate} />,
}, },
{ {
path: "/tenant/ai/knowledge", path: "/tenant/ai/knowledge",

View File

@ -134,6 +134,7 @@ class AIService {
max_tokens?: number; max_tokens?: number;
variables?: Array<{ name: string; type?: "string" | "number" | "boolean" | "array"; required?: boolean }>; variables?: Array<{ name: string; type?: "string" | "number" | "boolean" | "array"; required?: boolean }>;
tags?: string[]; tags?: string[];
is_default?: boolean;
}): Promise<AIPrompt> { }): Promise<AIPrompt> {
const response = await apiClient.post("/ai/prompts", payload); const response = await apiClient.post("/ai/prompts", payload);
return unwrap<AIPrompt>(response); return unwrap<AIPrompt>(response);