feat: implement AI service and prompt management pages for tenant configuration

This commit is contained in:
sibarchannayak 2026-05-29 18:01:31 +05:30
parent b16e6d9c18
commit 85ecd9dac1
5 changed files with 161 additions and 7 deletions

View File

@ -9,9 +9,11 @@ import {
SecondaryButton,
FormSlider,
FormTagInput,
MultiselectPaginatedSelect,
} from "@/components/shared";
import { Plus, Trash2 } from "lucide-react";
import { aiService } from "@/services/ai-service";
import { moduleService } from "@/services/module-service";
import { showToast } from "@/utils/toast";
import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@ -39,6 +41,7 @@ const promptSchema = z.object({
tags: z.array(z.string()),
is_default: z.boolean(),
variables: z.array(variableSchema),
module_ids: z.array(z.string().uuid()).optional(),
});
type PromptFormData = z.infer<typeof promptSchema>;
@ -47,6 +50,7 @@ const PromptCreate = (): ReactElement => {
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
const {
control,
@ -76,6 +80,7 @@ const PromptCreate = (): ReactElement => {
default: "",
},
],
module_ids: [],
},
});
@ -100,19 +105,53 @@ const PromptCreate = (): ReactElement => {
useEffect(() => {
const loadMeta = async (): Promise<void> => {
try {
const providerData = await aiService.getProviders();
const [providerData, modulesRes] = await Promise.all([
aiService.getProviders(),
moduleService.getMyModules(),
]);
setApiProviders(providerData);
setModules(
(modulesRes.data || []).map((m: any) => ({
value: m.id,
label: m.name,
}))
);
} catch (err: unknown) {
showToast.error(
(err as { response?: { data?: { error?: { message?: string } } } })
?.response?.data?.error?.message ||
"Failed to load provider metadata",
"Failed to load provider or module metadata",
);
}
};
void loadMeta();
}, []);
const handleLoadModules = async (
page: number,
limit: number,
search?: string
) => {
let filtered = modules;
if (search) {
filtered = modules.filter((m) =>
m.label.toLowerCase().includes(search.toLowerCase())
);
}
const startIndex = (page - 1) * limit;
const paginatedOptions = filtered.slice(startIndex, startIndex + limit);
return {
options: paginatedOptions,
pagination: {
page,
limit,
total: filtered.length,
totalPages: Math.ceil(filtered.length / limit),
hasMore: startIndex + limit < filtered.length,
},
};
};
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
const parsedTags = data.tags || [];
@ -140,6 +179,7 @@ const PromptCreate = (): ReactElement => {
variables: sanitizedVariables,
tags: parsedTags,
is_default: data.is_default,
module_ids: data.module_ids,
});
showToast.success("Prompt created successfully");
navigate("/tenant/ai/prompts");
@ -559,6 +599,23 @@ const PromptCreate = (): ReactElement => {
<h2 className="text-sm font-semibold text-gray-800">
Organization
</h2>
<Controller
name="module_ids"
control={control}
render={({ field }) => (
<MultiselectPaginatedSelect
label="Associated Modules"
placeholder="Select Modules"
value={field.value || []}
onValueChange={field.onChange}
onLoadOptions={handleLoadModules}
initialOptions={modules}
isSearchable
multiple
error={errors.module_ids?.message}
/>
)}
/>
<Controller
name="tags"
control={control}

View File

@ -8,10 +8,12 @@ import {
PrimaryButton,
SecondaryButton,
FormSlider,
FormTagInput
FormTagInput,
MultiselectPaginatedSelect
} from "@/components/shared";
import { Plus, Trash2 } from "lucide-react";
import { aiService } from "@/services/ai-service";
import { moduleService } from "@/services/module-service";
import { showToast } from "@/utils/toast";
import { useForm, useFieldArray, Controller } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
@ -40,6 +42,7 @@ const promptSchema = z.object({
is_default: z.boolean(),
variables: z.array(variableSchema),
change_notes: z.string().optional(),
module_ids: z.array(z.string().uuid()).optional(),
});
type PromptFormData = z.infer<typeof promptSchema>;
@ -50,6 +53,7 @@ const PromptEdit = (): ReactElement => {
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
const [currentVersion, setCurrentVersion] = useState<number>(1);
const {
@ -75,6 +79,7 @@ const PromptEdit = (): ReactElement => {
is_default: false,
variables: [],
change_notes: "",
module_ids: [],
},
});
@ -101,12 +106,19 @@ const PromptEdit = (): ReactElement => {
if (!id) return;
try {
setIsLoading(true);
const [providerData, promptData] = await Promise.all([
const [providerData, promptData, modulesRes] = await Promise.all([
aiService.getProviders(),
aiService.getPrompt(id),
moduleService.getMyModules(),
]);
setApiProviders(providerData);
setModules(
(modulesRes.data || []).map((m: any) => ({
value: m.id,
label: m.name,
}))
);
reset({
name: promptData.name,
@ -127,6 +139,7 @@ const PromptEdit = (): ReactElement => {
default: v.default ? String(v.default) : "",
})),
change_notes: "",
module_ids: promptData.moduleIds || [],
});
setCurrentVersion(promptData.version || 1);
} catch (err: unknown) {
@ -143,6 +156,31 @@ const PromptEdit = (): ReactElement => {
void loadData();
}, [id, reset, navigate]);
const handleLoadModules = async (
page: number,
limit: number,
search?: string
) => {
let filtered = modules;
if (search) {
filtered = modules.filter((m) =>
m.label.toLowerCase().includes(search.toLowerCase())
);
}
const startIndex = (page - 1) * limit;
const paginatedOptions = filtered.slice(startIndex, startIndex + limit);
return {
options: paginatedOptions,
pagination: {
page,
limit,
total: filtered.length,
totalPages: Math.ceil(filtered.length / limit),
hasMore: startIndex + limit < filtered.length,
},
};
};
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
if (!id) return;
const parsedTags = data.tags || [];
@ -172,6 +210,7 @@ const PromptEdit = (): ReactElement => {
tags: parsedTags,
is_default: data.is_default,
change_notes: data.change_notes,
module_ids: data.module_ids,
});
showToast.success("Prompt updated successfully");
navigate("/tenant/ai/prompts");
@ -601,6 +640,23 @@ const PromptEdit = (): ReactElement => {
<h2 className="text-sm font-semibold text-gray-800">
Organization
</h2>
<Controller
name="module_ids"
control={control}
render={({ field }) => (
<MultiselectPaginatedSelect
label="Associated Modules"
placeholder="Select Modules"
value={field.value || []}
onValueChange={field.onChange}
onLoadOptions={handleLoadModules}
initialOptions={modules}
isSearchable
multiple
error={errors.module_ids?.message}
/>
)}
/>
<Controller
name="tags"
control={control}

View File

@ -19,8 +19,10 @@ import {
ActionDropdown,
DeleteConfirmationModal,
SearchBox,
FilterDropdown,
} from "@/components/shared";
import { aiService } from "@/services/ai-service";
import { moduleService } from "@/services/module-service";
import type { AIPrompt } from "@/types/ai";
import { showToast } from "@/utils/toast";
import { formatDate } from "@/utils/format-date";
@ -43,6 +45,10 @@ const PromptManagement = (): ReactElement => {
totalPages: 1,
});
// Module filter states
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
const [selectedModuleId, setSelectedModuleId] = useState<string>("");
// Modal states
const [selectedPrompt, setSelectedPrompt] = useState<AIPrompt | null>(null);
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
@ -57,6 +63,24 @@ const PromptManagement = (): ReactElement => {
return () => clearTimeout(timer);
}, [search]);
// Load modules list on mount
useEffect(() => {
const loadModules = async (): Promise<void> => {
try {
const res = await moduleService.getMyModules();
setModules(
(res.data || []).map((m: any) => ({
value: m.id,
label: m.name,
}))
);
} catch (err: any) {
console.error("Failed to load modules list", err);
}
};
void loadModules();
}, []);
const loadPrompts = async (): Promise<void> => {
setIsLoading(true);
setError(null);
@ -65,6 +89,7 @@ const PromptManagement = (): ReactElement => {
page,
limit,
search: debouncedSearch || undefined,
module_id: selectedModuleId || undefined,
});
setPrompts(result.data || []);
setPagination({
@ -86,7 +111,7 @@ const PromptManagement = (): ReactElement => {
useEffect(() => {
void loadPrompts();
}, [page, limit, debouncedSearch]);
}, [page, limit, debouncedSearch, selectedModuleId]);
const handleStatusToggle = async (prompt: AIPrompt) => {
const newStatus = prompt.status === "active" ? "draft" : "active";
@ -313,13 +338,25 @@ const PromptManagement = (): ReactElement => {
}}
>
<div className="overflow-hidden">
<div className="pb-2">
<div className="flex flex-col md:flex-row md:items-center gap-3 pb-2">
<SearchBox
value={search}
onChange={setSearch}
placeholder="Search by name or description..."
containerClassName="relative w-full md:w-80"
/>
{/* <div className="w-full md:w-60"> */}
<FilterDropdown
label="Module"
value={selectedModuleId || null}
onChange={(val) => {
setSelectedModuleId(val ? (Array.isArray(val) ? val[0] : val) : "");
setPage(1);
}}
options={modules.map((m) => ({ value: m.value, label: m.label }))}
placeholder="All Modules"
/>
{/* </div> */}
</div>
<DataTable

View File

@ -149,6 +149,7 @@ class AIService {
variables?: Array<{ name: string; type?: "string" | "number" | "boolean" | "array"; required?: boolean }>;
tags?: string[];
is_default?: boolean;
module_ids?: string[];
}): Promise<AIPrompt> {
const response = await apiClient.post("/ai/prompts", payload);
return unwrap<AIPrompt>(response);
@ -173,6 +174,7 @@ class AIService {
tags?: string[];
is_default?: boolean;
change_notes?: string;
module_ids?: string[];
}): Promise<AIPrompt> {
const response = await apiClient.put(`/ai/prompts/${id}`, payload);
return unwrap<AIPrompt>(response);
@ -183,7 +185,7 @@ class AIService {
return unwrap<AIPrompt>(response);
}
async listPrompts(params: { page?: number; limit?: number; status?: string; search?: string } = {}): Promise<{
async listPrompts(params: { page?: number; limit?: number; status?: string; search?: string; module_id?: string } = {}): Promise<{
data: AIPrompt[];
pagination?: { page: number; limit: number; total: number; totalPages: number };
}> {

View File

@ -127,6 +127,8 @@ export interface AIPrompt {
createdAt?: string;
updatedAt?: string;
created_by_email?:string;
module_ids?: string[] | null;
moduleIds?: string[];
}
export interface KnowledgeCollection {