diff --git a/rsconcept/backend/apps/prompt/serializers/data_access.py b/rsconcept/backend/apps/prompt/serializers/data_access.py
index 603229b5..b4627f4f 100644
--- a/rsconcept/backend/apps/prompt/serializers/data_access.py
+++ b/rsconcept/backend/apps/prompt/serializers/data_access.py
@@ -17,7 +17,10 @@ class PromptTemplateSerializer(serializers.ModelSerializer):
def validate_label(self, value):
user = self.context['request'].user
- if PromptTemplate.objects.filter(owner=user, label=value).exists():
+ queryset = PromptTemplate.objects.filter(owner=user, label=value)
+ if self.instance is not None:
+ queryset = queryset.exclude(pk=self.instance.pk)
+ if queryset.exists():
raise serializers.ValidationError(msg.promptLabelTaken(value))
return value
diff --git a/rsconcept/frontend/src/app/global-dialogs.tsx b/rsconcept/frontend/src/app/global-dialogs.tsx
index ffc79178..b61c4de6 100644
--- a/rsconcept/frontend/src/app/global-dialogs.tsx
+++ b/rsconcept/frontend/src/app/global-dialogs.tsx
@@ -143,6 +143,11 @@ const DlgImportSchema = React.lazy(() =>
const DlgAIPromptDialog = React.lazy(() =>
import('@/features/ai/dialogs/dlg-ai-prompt').then(module => ({ default: module.DlgAIPromptDialog }))
);
+const DlgCreatePromptTemplate = React.lazy(() =>
+ import('@/features/ai/dialogs/dlg-create-prompt-template').then(module => ({
+ default: module.DlgCreatePromptTemplate
+ }))
+);
export const GlobalDialogs = () => {
const active = useDialogsStore(state => state.active);
@@ -213,5 +218,7 @@ export const GlobalDialogs = () => {
return ;
case DialogType.AI_PROMPT:
return ;
+ case DialogType.CREATE_PROMPT_TEMPLATE:
+ return ;
}
};
diff --git a/rsconcept/frontend/src/app/router.tsx b/rsconcept/frontend/src/app/router.tsx
index b4e408ed..f2cef55f 100644
--- a/rsconcept/frontend/src/app/router.tsx
+++ b/rsconcept/frontend/src/app/router.tsx
@@ -1,5 +1,6 @@
import { createBrowserRouter } from 'react-router';
+import { prefetchAvailableTemplates } from '@/features/ai/backend/use-available-templates';
import { prefetchAuth } from '@/features/auth/backend/use-auth';
import { LoginPage } from '@/features/auth/pages/login-page';
import { HomePage } from '@/features/home/home-page';
@@ -85,6 +86,11 @@ export const Router = createBrowserRouter([
path: `${routes.database_schema}`,
lazy: () => import('@/features/home/database-schema-page')
},
+ {
+ path: routes.prompt_templates,
+ loader: prefetchAvailableTemplates,
+ lazy: () => import('@/features/ai/pages/prompt-templates-page')
+ },
{
path: '*',
element:
diff --git a/rsconcept/frontend/src/app/urls.ts b/rsconcept/frontend/src/app/urls.ts
index 52452fc7..20198fbc 100644
--- a/rsconcept/frontend/src/app/urls.ts
+++ b/rsconcept/frontend/src/app/urls.ts
@@ -39,6 +39,8 @@ export const urls = {
library_filter: (strategy: string) => `/library?filter=${strategy}`,
create_schema: `/${routes.create_schema}`,
prompt_templates: `/${routes.prompt_templates}`,
+ prompt_template: (active: number | null, tab: number) =>
+ `/prompt-templates?tab=${tab}${active ? `&active=${active}` : ''}`,
manuals: `/${routes.manuals}`,
help_topic: (topic: string) => `/manuals?topic=${topic}`,
schema: (id: number | string, version?: number | string) =>
diff --git a/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx b/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx
index 0767f58e..deb37c1b 100644
--- a/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx
+++ b/rsconcept/frontend/src/features/ai/backend/use-available-templates.tsx
@@ -1,17 +1,23 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
+import { queryClient } from '@/backend/query-client';
+
import { promptsApi } from './api';
export function useAvailableTemplates() {
const { data, isLoading, error } = useQuery({
...promptsApi.getAvailableTemplatesQueryOptions()
});
- return { data, isLoading, error };
+ return { items: data, isLoading, error };
}
export function useAvailableTemplatesSuspense() {
const { data } = useSuspenseQuery({
...promptsApi.getAvailableTemplatesQueryOptions()
});
- return { data };
+ return { items: data };
+}
+
+export function prefetchAvailableTemplates() {
+ return queryClient.prefetchQuery(promptsApi.getAvailableTemplatesQueryOptions());
}
diff --git a/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx
index 2fe39e82..bc382abe 100644
--- a/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx
+++ b/rsconcept/frontend/src/features/ai/backend/use-create-prompt-template.tsx
@@ -1,11 +1,13 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { KEYS } from '@/backend/configuration';
+
import { promptsApi } from './api';
export function useCreatePromptTemplate() {
const client = useQueryClient();
const mutation = useMutation({
- mutationKey: [promptsApi.baseKey, 'create'],
+ mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'create'],
mutationFn: promptsApi.createPromptTemplate,
onSuccess: () => {
void client.invalidateQueries({ queryKey: [promptsApi.baseKey] });
diff --git a/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx
index 06d764e9..f04b57e3 100644
--- a/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx
+++ b/rsconcept/frontend/src/features/ai/backend/use-delete-prompt-template.tsx
@@ -1,13 +1,15 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { KEYS } from '@/backend/configuration';
+
import { promptsApi } from './api';
export function useDeletePromptTemplate() {
const client = useQueryClient();
const mutation = useMutation({
- mutationKey: [promptsApi.baseKey, 'delete'],
+ mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'delete'],
mutationFn: promptsApi.deletePromptTemplate,
- onSuccess: (_data, id) => {
+ onSuccess: (_, id) => {
void client.invalidateQueries({ queryKey: [promptsApi.baseKey] });
void client.invalidateQueries({ queryKey: [promptsApi.baseKey, id] });
}
diff --git a/rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx b/rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx
new file mode 100644
index 00000000..bf181adc
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/backend/use-mutating-prompts.tsx
@@ -0,0 +1,10 @@
+import { useIsMutating } from '@tanstack/react-query';
+
+import { KEYS } from '@/backend/configuration';
+
+import { promptsApi } from './api';
+
+export const useMutatingPrompts = () => {
+ const countMutations = useIsMutating({ mutationKey: [KEYS.global_mutation, promptsApi.baseKey] });
+ return countMutations !== 0;
+};
diff --git a/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx
index 73f18b29..ceb96820 100644
--- a/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx
+++ b/rsconcept/frontend/src/features/ai/backend/use-prompt-template.tsx
@@ -1,17 +1,23 @@
import { useQuery, useSuspenseQuery } from '@tanstack/react-query';
+import { queryClient } from '@/backend/query-client';
+
import { promptsApi } from './api';
export function usePromptTemplate(id: number) {
const { data, isLoading, error } = useQuery({
...promptsApi.getPromptTemplateQueryOptions(id)
});
- return { data, isLoading, error };
+ return { promptTemplate: data, isLoading, error };
}
export function usePromptTemplateSuspense(id: number) {
const { data } = useSuspenseQuery({
...promptsApi.getPromptTemplateQueryOptions(id)
});
- return { data };
+ return { promptTemplate: data };
+}
+
+export function prefetchPromptTemplate({ itemID }: { itemID: number }) {
+ return queryClient.prefetchQuery(promptsApi.getPromptTemplateQueryOptions(itemID));
}
diff --git a/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx b/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx
index 2a18fe8a..17a89545 100644
--- a/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx
+++ b/rsconcept/frontend/src/features/ai/backend/use-update-prompt-template.tsx
@@ -1,16 +1,21 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { KEYS } from '@/backend/configuration';
+
import { promptsApi } from './api';
-import { type IUpdatePromptTemplateDTO } from './types';
+import { type IPromptTemplateDTO, type IUpdatePromptTemplateDTO } from './types';
export function useUpdatePromptTemplate() {
const client = useQueryClient();
const mutation = useMutation({
- mutationKey: [promptsApi.baseKey, 'update'],
+ mutationKey: [KEYS.global_mutation, promptsApi.baseKey, 'update'],
mutationFn: ({ id, data }: { id: number; data: IUpdatePromptTemplateDTO }) =>
promptsApi.updatePromptTemplate(id, data),
- onSuccess: (_, variables) => {
- void client.invalidateQueries({ queryKey: [promptsApi.baseKey, variables.id] });
+ onSuccess: (data: IPromptTemplateDTO) => {
+ client.setQueryData(promptsApi.getPromptTemplateQueryOptions(data.id).queryKey, data);
+ client.setQueryData(promptsApi.getAvailableTemplatesQueryOptions().queryKey, prev =>
+ prev?.map(item => (item.id === data.id ? data : item))
+ );
}
});
return {
diff --git a/rsconcept/frontend/src/features/ai/components/badge-shared-template.tsx b/rsconcept/frontend/src/features/ai/components/badge-shared-template.tsx
new file mode 100644
index 00000000..d6da3bdb
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/components/badge-shared-template.tsx
@@ -0,0 +1,18 @@
+import { globalIDs } from '@/utils/constants';
+
+import { IconSharedTemplate } from './icon-shared-template';
+
+interface BadgeSharedTemplateProps {
+ value: boolean;
+}
+
+/**
+ * Displays location icon with a full text tooltip.
+ */
+export function BadgeSharedTemplate({ value }: BadgeSharedTemplateProps) {
+ return (
+
+
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx b/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx
new file mode 100644
index 00000000..7aa41e99
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/components/icon-shared-template.tsx
@@ -0,0 +1,10 @@
+import { type DomIconProps, IconPrivate, IconPublic } from '@/components/icons';
+
+/** Icon for shared template flag. */
+export function IconSharedTemplate({ value, size = '1.25rem', className }: DomIconProps) {
+ if (value) {
+ return ;
+ } else {
+ return ;
+ }
+}
diff --git a/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx b/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx
new file mode 100644
index 00000000..954e748d
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/dialogs/dlg-create-prompt-template.tsx
@@ -0,0 +1,70 @@
+import { Controller, useForm, useWatch } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { useAuthSuspense } from '@/features/auth';
+
+import { Checkbox, TextArea, TextInput } from '@/components/input';
+import { ModalForm } from '@/components/modal';
+import { useDialogsStore } from '@/stores/dialogs';
+import { type RO } from '@/utils/meta';
+
+import { type ICreatePromptTemplateDTO, type IPromptTemplateDTO, schemaCreatePromptTemplate } from '../backend/types';
+import { useAvailableTemplatesSuspense } from '../backend/use-available-templates';
+import { useCreatePromptTemplate } from '../backend/use-create-prompt-template';
+
+export interface DlgCreatePromptTemplateProps {
+ onCreate?: (data: RO) => void;
+}
+
+export function DlgCreatePromptTemplate() {
+ const { onCreate } = useDialogsStore(state => state.props as DlgCreatePromptTemplateProps);
+ const { createPromptTemplate } = useCreatePromptTemplate();
+ const { items: templates } = useAvailableTemplatesSuspense();
+ const { user } = useAuthSuspense();
+
+ const { handleSubmit, control, register } = useForm({
+ resolver: zodResolver(schemaCreatePromptTemplate),
+ defaultValues: {
+ label: '',
+ description: '',
+ text: '',
+ is_shared: false
+ }
+ });
+ const label = useWatch({ control, name: 'label' });
+ const isValid = label !== '' && !templates.find(template => template.label === label);
+
+ function onSubmit(data: ICreatePromptTemplateDTO) {
+ void createPromptTemplate(data).then(onCreate);
+ }
+
+ return (
+ void handleSubmit(onSubmit)(event)}
+ submitInvalidTooltip='Введите уникальное название шаблона'
+ className='cc-column w-140 max-h-120 py-2 px-6'
+ >
+
+
+ {user.is_staff ? (
+ (
+
+ )}
+ />
+ ) : null}
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/dialogs/index.tsx b/rsconcept/frontend/src/features/ai/dialogs/index.tsx
deleted file mode 100644
index 8974e3aa..00000000
--- a/rsconcept/frontend/src/features/ai/dialogs/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { DlgAIPromptDialog } from './dlg-ai-prompt';
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/index.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/index.tsx
new file mode 100644
index 00000000..7a9efcbe
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/index.tsx
@@ -0,0 +1 @@
+export { PromptTemplatesPage as Component } from './prompt-templates-page';
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/menu-templates.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/menu-templates.tsx
new file mode 100644
index 00000000..5cbd2004
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/menu-templates.tsx
@@ -0,0 +1,31 @@
+import { urls, useConceptNavigation } from '@/app';
+
+import { MiniButton } from '@/components/control';
+import { IconNewItem } from '@/components/icons';
+import { useDialogsStore } from '@/stores/dialogs';
+
+import { PromptTabID } from './templates-tabs';
+
+export function MenuTemplates() {
+ const router = useConceptNavigation();
+ const showCreatePromptTemplate = useDialogsStore(state => state.showCreatePromptTemplate);
+
+ function handleNewTemplate() {
+ showCreatePromptTemplate({
+ onCreate: data => router.push({ path: urls.prompt_template(data.id, PromptTabID.EDIT) })
+ });
+ }
+
+ return (
+
+ }
+ className='h-full text-muted-foreground hover:text-constructive cc-animate-color bg-transparent'
+ onClick={handleNewTemplate}
+ />
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx
new file mode 100644
index 00000000..5ab64b06
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/prompt-templates-page.tsx
@@ -0,0 +1,58 @@
+import { ErrorBoundary } from 'react-error-boundary';
+import { isAxiosError } from 'axios';
+import { z } from 'zod';
+
+import { useBlockNavigation } from '@/app';
+import { routes } from '@/app/urls';
+import { RequireAuth } from '@/features/auth/components/require-auth';
+
+import { TextURL } from '@/components/control';
+import { type ErrorData } from '@/components/info-error';
+import { useQueryStrings } from '@/hooks/use-query-strings';
+import { useModificationStore } from '@/stores/modification';
+
+import { PromptTabID, TemplatesTabs } from './templates-tabs';
+
+const paramsSchema = z.strictObject({
+ tab: z.preprocess(v => (v ? Number(v) : undefined), z.nativeEnum(PromptTabID).default(PromptTabID.LIST)),
+ active: z.preprocess(v => (v ? Number(v) : undefined), z.number().nullable().default(null))
+});
+
+export function PromptTemplatesPage() {
+ const query = useQueryStrings();
+
+ const urlData = paramsSchema.parse({
+ tab: query.get('tab'),
+ active: query.get('active')
+ });
+
+ const { isModified } = useModificationStore();
+ useBlockNavigation(isModified);
+
+ return (
+ }
+ >
+
+
+
+
+ );
+}
+
+// ====== Internals =========
+function ProcessError({ error, itemID }: { error: ErrorData; itemID?: number | null }): React.ReactElement | null {
+ if (isAxiosError(error) && error.response) {
+ if (error.response.status === 404) {
+ return (
+
+
{`Шаблон запроса с указанным идентификатором ${itemID} отсутствует`}
+
+
+
+
+ );
+ }
+ }
+ throw error as Error;
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx
new file mode 100644
index 00000000..22961121
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/form-prompt-template.tsx
@@ -0,0 +1,126 @@
+'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
+'use client';
+
+import { useRef } from 'react';
+import { Controller, useForm } from 'react-hook-form';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+import { useMutatingPrompts } from '@/features/ai/backend/use-mutating-prompts';
+import { useUpdatePromptTemplate } from '@/features/ai/backend/use-update-prompt-template';
+import { useAuthSuspense } from '@/features/auth';
+
+import { Checkbox, TextArea, TextInput } from '@/components/input';
+import { cn } from '@/components/utils';
+import { useModificationStore } from '@/stores/modification';
+import { globalIDs } from '@/utils/constants';
+
+import {
+ type IPromptTemplate,
+ type IUpdatePromptTemplateDTO,
+ schemaUpdatePromptTemplate
+} from '../../../backend/types';
+
+interface FormPromptTemplateProps {
+ promptTemplate: IPromptTemplate;
+ isMutable: boolean;
+ className?: string;
+ toggleReset: boolean;
+}
+
+/** Form for editing a prompt template. */
+export function FormPromptTemplate({ promptTemplate, className, isMutable, toggleReset }: FormPromptTemplateProps) {
+ const { user } = useAuthSuspense();
+ const isProcessing = useMutatingPrompts();
+ const setIsModified = useModificationStore(state => state.setIsModified);
+ const { updatePromptTemplate } = useUpdatePromptTemplate();
+
+ const {
+ control,
+ handleSubmit,
+ reset,
+ register,
+ formState: { isDirty, errors }
+ } = useForm({
+ resolver: zodResolver(schemaUpdatePromptTemplate),
+ defaultValues: {
+ owner: promptTemplate.owner,
+ label: promptTemplate.label,
+ description: promptTemplate.description,
+ text: promptTemplate.text,
+ is_shared: promptTemplate.is_shared
+ }
+ });
+
+ const prevReset = useRef(toggleReset);
+ const prevTemplate = useRef(promptTemplate);
+ if (prevTemplate.current !== promptTemplate || prevReset.current !== toggleReset) {
+ prevTemplate.current = promptTemplate;
+ prevReset.current = toggleReset;
+ reset({
+ owner: promptTemplate.owner,
+ label: promptTemplate.label,
+ description: promptTemplate.description,
+ text: promptTemplate.text,
+ is_shared: promptTemplate.is_shared
+ });
+ }
+
+ const prevDirty = useRef(isDirty);
+ if (prevDirty.current !== isDirty) {
+ prevDirty.current = isDirty;
+ setIsModified(isDirty);
+ }
+
+ function onSubmit(data: IUpdatePromptTemplateDTO) {
+ return updatePromptTemplate({ id: promptTemplate.id, data }).then(() => {
+ setIsModified(false);
+ reset({ ...data });
+ });
+ }
+
+ return (
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/index.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/index.tsx
new file mode 100644
index 00000000..b844b820
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/index.tsx
@@ -0,0 +1 @@
+export { TabEditTemplate } from './tab-edit-template';
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/tab-edit-template.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/tab-edit-template.tsx
new file mode 100644
index 00000000..d7e51586
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/tab-edit-template.tsx
@@ -0,0 +1,69 @@
+import { useState } from 'react';
+import clsx from 'clsx';
+
+import { useAuthSuspense } from '@/features/auth';
+
+import { useModificationStore } from '@/stores/modification';
+import { globalIDs } from '@/utils/constants';
+
+import { usePromptTemplateSuspense } from '../../../backend/use-prompt-template';
+
+import { FormPromptTemplate } from './form-prompt-template';
+import { ToolbarTemplate } from './toolbar-template';
+
+interface TabEditTemplateProps {
+ activeID: number;
+}
+
+export function TabEditTemplate({ activeID }: TabEditTemplateProps) {
+ const { promptTemplate } = usePromptTemplateSuspense(activeID);
+ const isModified = useModificationStore(state => state.isModified);
+ const setIsModified = useModificationStore(state => state.setIsModified);
+ const { user } = useAuthSuspense();
+ const isMutable = user.is_staff || promptTemplate.owner === user.id;
+ const [toggleReset, setToggleReset] = useState(false);
+
+ function handleReset() {
+ setToggleReset(t => !t);
+ setIsModified(false);
+ }
+
+ function triggerFormSubmit() {
+ const form = document.getElementById(globalIDs.prompt_editor) as HTMLFormElement | null;
+ if (form) {
+ form.requestSubmit();
+ }
+ }
+
+ function handleInput(event: React.KeyboardEvent) {
+ if ((event.ctrlKey || event.metaKey) && event.code === 'KeyS') {
+ if (isModified) {
+ triggerFormSubmit();
+ }
+ event.preventDefault();
+ return;
+ }
+ }
+
+ return (
+
+ {isMutable ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/toolbar-template.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/toolbar-template.tsx
new file mode 100644
index 00000000..b479c17d
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-edit-template/toolbar-template.tsx
@@ -0,0 +1,61 @@
+import { urls, useConceptNavigation } from '@/app';
+import { useDeletePromptTemplate } from '@/features/ai/backend/use-delete-prompt-template';
+import { useMutatingPrompts } from '@/features/ai/backend/use-mutating-prompts';
+
+import { MiniButton } from '@/components/control';
+import { IconDestroy, IconReset, IconSave } from '@/components/icons';
+import { cn } from '@/components/utils';
+import { useModificationStore } from '@/stores/modification';
+import { promptText } from '@/utils/labels';
+import { isMac, prepareTooltip } from '@/utils/utils';
+
+import { PromptTabID } from '../templates-tabs';
+
+interface ToolbarTemplateProps {
+ activeID: number;
+ onSave: () => void;
+ onReset: () => void;
+ className?: string;
+}
+
+/** Toolbar for prompt template editing. */
+export function ToolbarTemplate({ activeID, onSave, onReset, className }: ToolbarTemplateProps) {
+ const router = useConceptNavigation();
+ const { deletePromptTemplate } = useDeletePromptTemplate();
+ const isProcessing = useMutatingPrompts();
+ const isModified = useModificationStore(state => state.isModified);
+
+ function handleDelete() {
+ if (window.confirm(promptText.deleteTemplate)) {
+ void deletePromptTemplate(activeID).then(() =>
+ router.pushAsync({ path: urls.prompt_template(null, PromptTabID.LIST) })
+ );
+ }
+ }
+
+ return (
+
+ }
+ onClick={onSave}
+ disabled={isProcessing || !isModified}
+ />
+ }
+ onClick={onReset}
+ disabled={isProcessing || !isModified}
+ />
+ }
+ onClick={handleDelete}
+ disabled={isProcessing}
+ />
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-list-templates.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-list-templates.tsx
new file mode 100644
index 00000000..3de10bc2
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-list-templates.tsx
@@ -0,0 +1,116 @@
+import { urls, useConceptNavigation } from '@/app';
+import { type IPromptTemplate } from '@/features/ai/backend/types';
+import { useLabelUser } from '@/features/users';
+
+import { TextURL } from '@/components/control';
+import { createColumnHelper, DataTable, type IConditionalStyle } from '@/components/data-table';
+import { NoData } from '@/components/view';
+import { useDialogsStore } from '@/stores/dialogs';
+import { type RO } from '@/utils/meta';
+
+import { useAvailableTemplatesSuspense } from '../../backend/use-available-templates';
+import { BadgeSharedTemplate } from '../../components/badge-shared-template';
+
+import { PromptTabID } from './templates-tabs';
+
+const columnHelper = createColumnHelper>();
+
+interface TabListTemplatesProps {
+ activeID: number | null;
+}
+
+export function TabListTemplates({ activeID }: TabListTemplatesProps) {
+ const router = useConceptNavigation();
+ const { items } = useAvailableTemplatesSuspense();
+ const showCreatePromptTemplate = useDialogsStore(state => state.showCreatePromptTemplate);
+ const getUserLabel = useLabelUser();
+
+ function handleRowDoubleClicked(row: RO, event: React.MouseEvent) {
+ event.preventDefault();
+ event.stopPropagation();
+ router.push({ path: urls.prompt_template(row.id, PromptTabID.EDIT), newTab: event.ctrlKey || event.metaKey });
+ }
+
+ function handleRowClicked(row: RO, event: React.MouseEvent) {
+ if (row.id === activeID) {
+ return;
+ }
+ router.push({ path: urls.prompt_template(row.id, PromptTabID.LIST), newTab: event.ctrlKey || event.metaKey });
+ }
+
+ function handleCreateNew() {
+ showCreatePromptTemplate({});
+ }
+
+ const columns = [
+ columnHelper.accessor('is_shared', {
+ id: 'is_shared',
+ header: '',
+ size: 50,
+ minSize: 50,
+ maxSize: 50,
+ enableSorting: true,
+ cell: props => ,
+ sortingFn: 'text'
+ }),
+ columnHelper.accessor('label', {
+ id: 'label',
+ header: 'Название',
+ size: 200,
+ minSize: 200,
+ maxSize: 200,
+ enableSorting: true,
+ cell: props => {props.getValue()},
+ sortingFn: 'text'
+ }),
+ columnHelper.accessor('description', {
+ id: 'description',
+ header: 'Описание',
+ size: 1200,
+ minSize: 200,
+ maxSize: 1200,
+ enableSorting: true,
+ sortingFn: 'text'
+ }),
+ columnHelper.accessor('owner', {
+ id: 'owner',
+ header: 'Владелец',
+ size: 400,
+ minSize: 100,
+ maxSize: 400,
+ cell: props => getUserLabel(props.getValue()),
+ enableSorting: true,
+ sortingFn: 'text'
+ })
+ ];
+
+ const conditionalRowStyles: IConditionalStyle>[] = [
+ {
+ when: (template: RO) => template.id === activeID,
+ className: 'bg-selected'
+ }
+ ];
+
+ return (
+
+
+ Список пуст
+
+
+
+
+ }
+ />
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-view-variables.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-view-variables.tsx
new file mode 100644
index 00000000..896eff82
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/tab-view-variables.tsx
@@ -0,0 +1,3 @@
+export function TabViewVariables() {
+ return View all variables
;
+}
diff --git a/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/templates-tabs.tsx b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/templates-tabs.tsx
new file mode 100644
index 00000000..e93ee1f1
--- /dev/null
+++ b/rsconcept/frontend/src/features/ai/pages/prompt-templates-page/templates-tabs.tsx
@@ -0,0 +1,83 @@
+import { Suspense } from 'react';
+
+import { urls, useConceptNavigation } from '@/app';
+
+import { Loader } from '@/components/loader';
+import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
+
+import { MenuTemplates } from './menu-templates';
+import { TabEditTemplate } from './tab-edit-template';
+import { TabListTemplates } from './tab-list-templates';
+import { TabViewVariables } from './tab-view-variables';
+
+export const PromptTabID = {
+ LIST: 0,
+ EDIT: 1,
+ VARIABLES: 2
+} as const;
+export type PromptTabID = (typeof PromptTabID)[keyof typeof PromptTabID];
+
+interface TemplatesTabsProps {
+ activeID: number | null;
+ tab: PromptTabID;
+}
+
+function TabLoader() {
+ return (
+
+
+
+ );
+}
+
+export function TemplatesTabs({ activeID, tab }: TemplatesTabsProps) {
+ const router = useConceptNavigation();
+
+ function onSelectTab(index: number, last: number, event: Event) {
+ if (last === index) {
+ return;
+ }
+ if (event.type == 'keydown') {
+ const kbEvent = event as KeyboardEvent;
+ if (kbEvent.altKey) {
+ if (kbEvent.code === 'ArrowLeft') {
+ router.back();
+ return;
+ } else if (kbEvent.code === 'ArrowRight') {
+ router.forward();
+ return;
+ }
+ }
+ }
+ router.replace({ path: urls.prompt_template(activeID, index as PromptTabID) });
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+ }>
+
+
+
+
+ {activeID ? (
+ }>
+ {' '}
+ {' '}
+
+ ) : null}
+
+
+
+
+
+
+ );
+}
diff --git a/rsconcept/frontend/src/features/library/pages/library-page/use-library-columns.tsx b/rsconcept/frontend/src/features/library/pages/library-page/use-library-columns.tsx
index 7be5e78e..1b8192c2 100644
--- a/rsconcept/frontend/src/features/library/pages/library-page/use-library-columns.tsx
+++ b/rsconcept/frontend/src/features/library/pages/library-page/use-library-columns.tsx
@@ -53,7 +53,7 @@ export function useLibraryColumns() {
enableSorting: true,
sortingFn: 'text'
}),
- columnHelper.accessor(item => item.owner ?? 0, {
+ columnHelper.accessor('owner', {
id: 'owner',
header: 'Владелец',
size: 400,
diff --git a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx
index 994b0c6e..50f9e10e 100644
--- a/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx
+++ b/rsconcept/frontend/src/features/oss/pages/oss-page/editor-oss-card/form-oss.tsx
@@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
-import { useEffect, useRef } from 'react';
+import { useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -47,12 +47,10 @@ export function FormOSS() {
const readOnly = useWatch({ control, name: 'read_only' });
const prevDirty = useRef(isDirty);
- useEffect(() => {
- if (prevDirty.current !== isDirty) {
- prevDirty.current = isDirty;
- setIsModified(isDirty);
- }
- }, [isDirty, setIsModified]);
+ if (prevDirty.current !== isDirty) {
+ prevDirty.current = isDirty;
+ setIsModified(isDirty);
+ }
function onSubmit(data: IUpdateLibraryItemDTO) {
return updateOss(data).then(() => reset({ ...data }));
diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx
index df74a807..063f8e55 100644
--- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx
+++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-constituenta/form-constituenta.tsx
@@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
-import { useEffect, useMemo, useRef, useState } from 'react';
+import { useMemo, useRef, useState } from 'react';
import { Controller, useForm } from 'react-hook-form';
import { toast } from 'react-toastify';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -121,15 +121,13 @@ export function FormConstituenta({ disabled, id, toggleReset, schema, activeCst,
}
const prevDirty = useRef(isDirty);
- useEffect(() => {
- if (prevDirty.current !== isDirty) {
- prevDirty.current = isDirty;
- setIsModified(isDirty);
- }
- }, [isDirty, setIsModified]);
+ if (prevDirty.current !== isDirty) {
+ prevDirty.current = isDirty;
+ setIsModified(isDirty);
+ }
function onSubmit(data: IUpdateConstituentaDTO) {
- return cstUpdate({ itemID: schema.id, data }).then(() => {
+ void cstUpdate({ itemID: schema.id, data }).then(() => {
setIsModified(false);
reset({ ...data });
});
diff --git a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx
index 1afb04eb..a7cb45ed 100644
--- a/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx
+++ b/rsconcept/frontend/src/features/rsform/pages/rsform-page/editor-rsform-card/form-rsform.tsx
@@ -1,7 +1,7 @@
'use no memo'; // TODO: remove when react hook forms are compliant with react compiler
'use client';
-import { useEffect, useRef } from 'react';
+import { useRef } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -71,12 +71,10 @@ export function FormRSForm() {
}
const prevDirty = useRef(isDirty);
- useEffect(() => {
- if (prevDirty.current !== isDirty) {
- prevDirty.current = isDirty;
- setIsModified(isDirty);
- }
- }, [isDirty, setIsModified]);
+ if (prevDirty.current !== isDirty) {
+ prevDirty.current = isDirty;
+ setIsModified(isDirty);
+ }
function handleSelectVersion(version: CurrentVersion) {
router.push({ path: urls.schema(schema.id, version === 'latest' ? undefined : version) });
diff --git a/rsconcept/frontend/src/stores/dialogs.ts b/rsconcept/frontend/src/stores/dialogs.ts
index edee5811..cbd19802 100644
--- a/rsconcept/frontend/src/stores/dialogs.ts
+++ b/rsconcept/frontend/src/stores/dialogs.ts
@@ -1,6 +1,7 @@
import { create } from 'zustand';
import { type DlgAIPromptDialogProps } from '@/features/ai/dialogs/dlg-ai-prompt';
+import { type DlgCreatePromptTemplateProps } from '@/features/ai/dialogs/dlg-create-prompt-template';
import { type DlgChangeLocationProps } from '@/features/library/dialogs/dlg-change-location';
import { type DlgCloneLibraryItemProps } from '@/features/library/dialogs/dlg-clone-library-item';
import { type DlgCreateVersionProps } from '@/features/library/dialogs/dlg-create-version';
@@ -69,7 +70,9 @@ export const DialogType = {
SHOW_TERM_GRAPH: 28,
CREATE_SCHEMA: 29,
IMPORT_SCHEMA: 30,
- AI_PROMPT: 31
+
+ AI_PROMPT: 31,
+ CREATE_PROMPT_TEMPLATE: 32
} as const;
export type DialogType = (typeof DialogType)[keyof typeof DialogType];
@@ -113,6 +116,7 @@ interface DialogsStore {
showCreateSchema: (props: DlgCreateSchemaProps) => void;
showImportSchema: (props: DlgImportSchemaProps) => void;
showAIPrompt: (props: DlgAIPromptDialogProps) => void;
+ showCreatePromptTemplate: (props: DlgCreatePromptTemplateProps) => void;
}
export const useDialogsStore = create()(set => ({
@@ -155,5 +159,6 @@ export const useDialogsStore = create()(set => ({
showEditCst: props => set({ active: DialogType.EDIT_CONSTITUENTA, props: props }),
showCreateSchema: props => set({ active: DialogType.CREATE_SCHEMA, props: props }),
showImportSchema: props => set({ active: DialogType.IMPORT_SCHEMA, props: props }),
- showAIPrompt: (props: DlgAIPromptDialogProps) => set({ active: DialogType.AI_PROMPT, props: props })
+ showAIPrompt: (props: DlgAIPromptDialogProps) => set({ active: DialogType.AI_PROMPT, props: props }),
+ showCreatePromptTemplate: props => set({ active: DialogType.CREATE_PROMPT_TEMPLATE, props: props })
}));
diff --git a/rsconcept/frontend/src/styling/overrides.css b/rsconcept/frontend/src/styling/overrides.css
index 31d1ca7b..4f55e7fd 100644
--- a/rsconcept/frontend/src/styling/overrides.css
+++ b/rsconcept/frontend/src/styling/overrides.css
@@ -23,7 +23,6 @@
/* React Tooltip */
--rt-color-white: var(--color-input);
--rt-color-dark: var(--color-foreground);
-
}
/* Dark Theme */
diff --git a/rsconcept/frontend/src/utils/constants.ts b/rsconcept/frontend/src/utils/constants.ts
index ca0933f2..fd00d14f 100644
--- a/rsconcept/frontend/src/utils/constants.ts
+++ b/rsconcept/frontend/src/utils/constants.ts
@@ -78,6 +78,7 @@ export const globalIDs = {
email_tooltip: 'email_tooltip',
library_item_editor: 'library_item_editor',
constituenta_editor: 'constituenta_editor',
+ prompt_editor: 'prompt_editor',
graph_schemas: 'graph_schemas_tooltip',
user_dropdown: 'user_dropdown',
ai_dropdown: 'ai_dropdown'
diff --git a/rsconcept/frontend/src/utils/labels.ts b/rsconcept/frontend/src/utils/labels.ts
index 889bb01f..3da3def7 100644
--- a/rsconcept/frontend/src/utils/labels.ts
+++ b/rsconcept/frontend/src/utils/labels.ts
@@ -82,6 +82,7 @@ export const promptText = {
promptUnsaved: 'Присутствуют несохраненные изменения. Продолжить без их учета?',
deleteLibraryItem: 'Вы уверены, что хотите удалить данную схему?',
deleteBlock: 'Вы уверены, что хотите удалить данный блок?',
+ deleteTemplate: 'Вы уверены, что хотите удалить данный шаблон?',
deleteOSS:
'Внимание!!\nУдаление операционной схемы приведет к удалению всех операций и собственных концептуальных схем.\nДанное действие нельзя отменить.\nВы уверены, что хотите удалить данную ОСС?',
generateWordforms: 'Данное действие приведет к перезаписи словоформ при совпадении граммем. Продолжить?',