feat: implement AI service and prompt management pages for tenant configuration
This commit is contained in:
parent
b16e6d9c18
commit
85ecd9dac1
@ -9,9 +9,11 @@ import {
|
|||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
FormSlider,
|
FormSlider,
|
||||||
FormTagInput,
|
FormTagInput,
|
||||||
|
MultiselectPaginatedSelect,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { aiService } from "@/services/ai-service";
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import { moduleService } from "@/services/module-service";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { useForm, useFieldArray, Controller } from "react-hook-form";
|
import { useForm, useFieldArray, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -39,6 +41,7 @@ const promptSchema = z.object({
|
|||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
is_default: z.boolean(),
|
is_default: z.boolean(),
|
||||||
variables: z.array(variableSchema),
|
variables: z.array(variableSchema),
|
||||||
|
module_ids: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type PromptFormData = z.infer<typeof promptSchema>;
|
type PromptFormData = z.infer<typeof promptSchema>;
|
||||||
@ -47,6 +50,7 @@ const PromptCreate = (): ReactElement => {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||||
|
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
control,
|
control,
|
||||||
@ -76,6 +80,7 @@ const PromptCreate = (): ReactElement => {
|
|||||||
default: "",
|
default: "",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
module_ids: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -100,19 +105,53 @@ const PromptCreate = (): ReactElement => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadMeta = async (): Promise<void> => {
|
const loadMeta = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
const providerData = await aiService.getProviders();
|
const [providerData, modulesRes] = await Promise.all([
|
||||||
|
aiService.getProviders(),
|
||||||
|
moduleService.getMyModules(),
|
||||||
|
]);
|
||||||
setApiProviders(providerData);
|
setApiProviders(providerData);
|
||||||
|
setModules(
|
||||||
|
(modulesRes.data || []).map((m: any) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: m.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
showToast.error(
|
showToast.error(
|
||||||
(err as { response?: { data?: { error?: { message?: string } } } })
|
(err as { response?: { data?: { error?: { message?: string } } } })
|
||||||
?.response?.data?.error?.message ||
|
?.response?.data?.error?.message ||
|
||||||
"Failed to load provider metadata",
|
"Failed to load provider or module metadata",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void loadMeta();
|
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 onFormSubmit = async (data: PromptFormData): Promise<void> => {
|
||||||
const parsedTags = data.tags || [];
|
const parsedTags = data.tags || [];
|
||||||
|
|
||||||
@ -140,6 +179,7 @@ const PromptCreate = (): ReactElement => {
|
|||||||
variables: sanitizedVariables,
|
variables: sanitizedVariables,
|
||||||
tags: parsedTags,
|
tags: parsedTags,
|
||||||
is_default: data.is_default,
|
is_default: data.is_default,
|
||||||
|
module_ids: data.module_ids,
|
||||||
});
|
});
|
||||||
showToast.success("Prompt created successfully");
|
showToast.success("Prompt created successfully");
|
||||||
navigate("/tenant/ai/prompts");
|
navigate("/tenant/ai/prompts");
|
||||||
@ -559,6 +599,23 @@ const PromptCreate = (): ReactElement => {
|
|||||||
<h2 className="text-sm font-semibold text-gray-800">
|
<h2 className="text-sm font-semibold text-gray-800">
|
||||||
Organization
|
Organization
|
||||||
</h2>
|
</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
|
<Controller
|
||||||
name="tags"
|
name="tags"
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -8,10 +8,12 @@ import {
|
|||||||
PrimaryButton,
|
PrimaryButton,
|
||||||
SecondaryButton,
|
SecondaryButton,
|
||||||
FormSlider,
|
FormSlider,
|
||||||
FormTagInput
|
FormTagInput,
|
||||||
|
MultiselectPaginatedSelect
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { aiService } from "@/services/ai-service";
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import { moduleService } from "@/services/module-service";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { useForm, useFieldArray, Controller } from "react-hook-form";
|
import { useForm, useFieldArray, Controller } from "react-hook-form";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
@ -40,6 +42,7 @@ const promptSchema = z.object({
|
|||||||
is_default: z.boolean(),
|
is_default: z.boolean(),
|
||||||
variables: z.array(variableSchema),
|
variables: z.array(variableSchema),
|
||||||
change_notes: z.string().optional(),
|
change_notes: z.string().optional(),
|
||||||
|
module_ids: z.array(z.string().uuid()).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
type PromptFormData = z.infer<typeof promptSchema>;
|
type PromptFormData = z.infer<typeof promptSchema>;
|
||||||
@ -50,6 +53,7 @@ const PromptEdit = (): ReactElement => {
|
|||||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||||
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
const [apiProviders, setApiProviders] = useState<AIProviderInfo[]>([]);
|
||||||
|
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
const [currentVersion, setCurrentVersion] = useState<number>(1);
|
const [currentVersion, setCurrentVersion] = useState<number>(1);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -75,6 +79,7 @@ const PromptEdit = (): ReactElement => {
|
|||||||
is_default: false,
|
is_default: false,
|
||||||
variables: [],
|
variables: [],
|
||||||
change_notes: "",
|
change_notes: "",
|
||||||
|
module_ids: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -101,12 +106,19 @@ const PromptEdit = (): ReactElement => {
|
|||||||
if (!id) return;
|
if (!id) return;
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const [providerData, promptData] = await Promise.all([
|
const [providerData, promptData, modulesRes] = await Promise.all([
|
||||||
aiService.getProviders(),
|
aiService.getProviders(),
|
||||||
aiService.getPrompt(id),
|
aiService.getPrompt(id),
|
||||||
|
moduleService.getMyModules(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
setApiProviders(providerData);
|
setApiProviders(providerData);
|
||||||
|
setModules(
|
||||||
|
(modulesRes.data || []).map((m: any) => ({
|
||||||
|
value: m.id,
|
||||||
|
label: m.name,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
reset({
|
reset({
|
||||||
name: promptData.name,
|
name: promptData.name,
|
||||||
@ -127,6 +139,7 @@ const PromptEdit = (): ReactElement => {
|
|||||||
default: v.default ? String(v.default) : "",
|
default: v.default ? String(v.default) : "",
|
||||||
})),
|
})),
|
||||||
change_notes: "",
|
change_notes: "",
|
||||||
|
module_ids: promptData.moduleIds || [],
|
||||||
});
|
});
|
||||||
setCurrentVersion(promptData.version || 1);
|
setCurrentVersion(promptData.version || 1);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@ -143,6 +156,31 @@ const PromptEdit = (): ReactElement => {
|
|||||||
void loadData();
|
void loadData();
|
||||||
}, [id, reset, navigate]);
|
}, [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> => {
|
const onFormSubmit = async (data: PromptFormData): Promise<void> => {
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
const parsedTags = data.tags || [];
|
const parsedTags = data.tags || [];
|
||||||
@ -172,6 +210,7 @@ const PromptEdit = (): ReactElement => {
|
|||||||
tags: parsedTags,
|
tags: parsedTags,
|
||||||
is_default: data.is_default,
|
is_default: data.is_default,
|
||||||
change_notes: data.change_notes,
|
change_notes: data.change_notes,
|
||||||
|
module_ids: data.module_ids,
|
||||||
});
|
});
|
||||||
showToast.success("Prompt updated successfully");
|
showToast.success("Prompt updated successfully");
|
||||||
navigate("/tenant/ai/prompts");
|
navigate("/tenant/ai/prompts");
|
||||||
@ -601,6 +640,23 @@ const PromptEdit = (): ReactElement => {
|
|||||||
<h2 className="text-sm font-semibold text-gray-800">
|
<h2 className="text-sm font-semibold text-gray-800">
|
||||||
Organization
|
Organization
|
||||||
</h2>
|
</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
|
<Controller
|
||||||
name="tags"
|
name="tags"
|
||||||
control={control}
|
control={control}
|
||||||
|
|||||||
@ -19,8 +19,10 @@ import {
|
|||||||
ActionDropdown,
|
ActionDropdown,
|
||||||
DeleteConfirmationModal,
|
DeleteConfirmationModal,
|
||||||
SearchBox,
|
SearchBox,
|
||||||
|
FilterDropdown,
|
||||||
} from "@/components/shared";
|
} from "@/components/shared";
|
||||||
import { aiService } from "@/services/ai-service";
|
import { aiService } from "@/services/ai-service";
|
||||||
|
import { moduleService } from "@/services/module-service";
|
||||||
import type { AIPrompt } from "@/types/ai";
|
import type { AIPrompt } from "@/types/ai";
|
||||||
import { showToast } from "@/utils/toast";
|
import { showToast } from "@/utils/toast";
|
||||||
import { formatDate } from "@/utils/format-date";
|
import { formatDate } from "@/utils/format-date";
|
||||||
@ -43,6 +45,10 @@ const PromptManagement = (): ReactElement => {
|
|||||||
totalPages: 1,
|
totalPages: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Module filter states
|
||||||
|
const [modules, setModules] = useState<Array<{ value: string; label: string }>>([]);
|
||||||
|
const [selectedModuleId, setSelectedModuleId] = useState<string>("");
|
||||||
|
|
||||||
// Modal states
|
// Modal states
|
||||||
const [selectedPrompt, setSelectedPrompt] = useState<AIPrompt | null>(null);
|
const [selectedPrompt, setSelectedPrompt] = useState<AIPrompt | null>(null);
|
||||||
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
|
const [isVersionsOpen, setIsVersionsOpen] = useState(false);
|
||||||
@ -57,6 +63,24 @@ const PromptManagement = (): ReactElement => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [search]);
|
}, [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> => {
|
const loadPrompts = async (): Promise<void> => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -65,6 +89,7 @@ const PromptManagement = (): ReactElement => {
|
|||||||
page,
|
page,
|
||||||
limit,
|
limit,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
|
module_id: selectedModuleId || undefined,
|
||||||
});
|
});
|
||||||
setPrompts(result.data || []);
|
setPrompts(result.data || []);
|
||||||
setPagination({
|
setPagination({
|
||||||
@ -86,7 +111,7 @@ const PromptManagement = (): ReactElement => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadPrompts();
|
void loadPrompts();
|
||||||
}, [page, limit, debouncedSearch]);
|
}, [page, limit, debouncedSearch, selectedModuleId]);
|
||||||
|
|
||||||
const handleStatusToggle = async (prompt: AIPrompt) => {
|
const handleStatusToggle = async (prompt: AIPrompt) => {
|
||||||
const newStatus = prompt.status === "active" ? "draft" : "active";
|
const newStatus = prompt.status === "active" ? "draft" : "active";
|
||||||
@ -313,13 +338,25 @@ const PromptManagement = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden">
|
<div className="overflow-hidden">
|
||||||
<div className="pb-2">
|
<div className="flex flex-col md:flex-row md:items-center gap-3 pb-2">
|
||||||
<SearchBox
|
<SearchBox
|
||||||
value={search}
|
value={search}
|
||||||
onChange={setSearch}
|
onChange={setSearch}
|
||||||
placeholder="Search by name or description..."
|
placeholder="Search by name or description..."
|
||||||
containerClassName="relative w-full md:w-80"
|
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>
|
</div>
|
||||||
|
|
||||||
<DataTable
|
<DataTable
|
||||||
|
|||||||
@ -149,6 +149,7 @@ class AIService {
|
|||||||
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;
|
is_default?: boolean;
|
||||||
|
module_ids?: string[];
|
||||||
}): 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);
|
||||||
@ -173,6 +174,7 @@ class AIService {
|
|||||||
tags?: string[];
|
tags?: string[];
|
||||||
is_default?: boolean;
|
is_default?: boolean;
|
||||||
change_notes?: string;
|
change_notes?: string;
|
||||||
|
module_ids?: string[];
|
||||||
}): Promise<AIPrompt> {
|
}): Promise<AIPrompt> {
|
||||||
const response = await apiClient.put(`/ai/prompts/${id}`, payload);
|
const response = await apiClient.put(`/ai/prompts/${id}`, payload);
|
||||||
return unwrap<AIPrompt>(response);
|
return unwrap<AIPrompt>(response);
|
||||||
@ -183,7 +185,7 @@ class AIService {
|
|||||||
return unwrap<AIPrompt>(response);
|
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[];
|
data: AIPrompt[];
|
||||||
pagination?: { page: number; limit: number; total: number; totalPages: number };
|
pagination?: { page: number; limit: number; total: number; totalPages: number };
|
||||||
}> {
|
}> {
|
||||||
|
|||||||
@ -127,6 +127,8 @@ export interface AIPrompt {
|
|||||||
createdAt?: string;
|
createdAt?: string;
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
created_by_email?:string;
|
created_by_email?:string;
|
||||||
|
module_ids?: string[] | null;
|
||||||
|
moduleIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface KnowledgeCollection {
|
export interface KnowledgeCollection {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user