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:
parent
93dd820fe2
commit
92ea75ce77
@ -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" },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@ -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")}>
|
||||||
|
|||||||
289
src/pages/tenant/PromptCreate.tsx
Normal file
289
src/pages/tenant/PromptCreate.tsx
Normal 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;
|
||||||
198
src/pages/tenant/PromptManagement.tsx
Normal file
198
src/pages/tenant/PromptManagement.tsx
Normal 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;
|
||||||
@ -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",
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user