F: Rework dialog for AI Prompts

This commit is contained in:
Ivan 2025-07-21 11:06:02 +03:00
parent f27d9df8d3
commit ab82f1aaed
14 changed files with 341 additions and 185 deletions

View File

@ -0,0 +1,81 @@
'use client';
import { useEffect, useState } from 'react';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
import { PromptVariableType } from '../../models/prompting';
import { extractPromptVariables } from '../../models/prompting-api';
import { evaluatePromptVariable, useAIStore } from '../../stores/ai-context';
import { MenuAIPrompt } from './menu-ai-prompt';
import { TabPromptEdit } from './tab-prompt-edit';
import { TabPromptResult } from './tab-prompt-result';
import { TabPromptVariables } from './tab-prompt-variables';
interface AIPromptTabsProps {
promptID: number;
activeTab: number;
setActiveTab: (value: TabID) => void;
}
export const TabID = {
TEMPLATE: 0,
RESULT: 1,
VARIABLES: 2
} as const;
type TabID = (typeof TabID)[keyof typeof TabID];
export function AIPromptTabs({ promptID, activeTab, setActiveTab }: AIPromptTabsProps) {
const context = useAIStore();
const { promptTemplate } = usePromptTemplateSuspense(promptID);
const [text, setText] = useState(promptTemplate.text);
const variables = extractPromptVariables(text);
const generatedPrompt = (() => {
let result = text;
for (const variable of variables) {
const type = Object.values(PromptVariableType).find(t => t === variable);
let value = '';
if (type) {
value = evaluatePromptVariable(type, context) ?? '';
}
result = result.replace(new RegExp(`\{\{${variable}\}\}`, 'g'), value || `${variable}`);
}
return result;
})();
useEffect(() => {
setText(promptTemplate.text);
}, [promptTemplate]);
return (
<Tabs selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
<TabList className='mx-auto w-fit flex border-x border-b divide-x rounded-none'>
<MenuAIPrompt promptID={promptID} generatedPrompt={generatedPrompt} />
<TabLabel label='Шаблон' />
<TabLabel label='Результат' />
<TabLabel label='Переменные' />
</TabList>
<div className='h-80 flex flex-col gap-2'>
<TabPanel>
<TabPromptEdit
text={text}
setText={setText}
label={promptTemplate.label}
description={promptTemplate.description}
/>
</TabPanel>
<TabPanel>
<TabPromptResult prompt={generatedPrompt} />
</TabPanel>
<TabPanel>
<TabPromptVariables template={text} />
</TabPanel>
</div>
</Tabs>
);
}

View File

@ -3,36 +3,18 @@ import { Suspense, useState } from 'react';
import { ComboBox } from '@/components/input/combo-box';
import { Loader } from '@/components/loader';
import { ModalView } from '@/components/modal';
import { TabLabel, TabList, TabPanel, Tabs } from '@/components/tabs';
import { useAvailableTemplatesSuspense } from '../../backend/use-available-templates';
import { TabPromptResult } from './tab-prompt-result';
import { TabPromptSelect } from './tab-prompt-select';
import { TabPromptVariables } from './tab-prompt-variables';
export const TabID = {
SELECT: 0,
RESULT: 1,
VARIABLES: 2
} as const;
type TabID = (typeof TabID)[keyof typeof TabID];
import { AIPromptTabs, TabID } from './ai-prompt-tabs';
export function DlgAIPromptDialog() {
const [activeTab, setActiveTab] = useState<TabID>(TabID.SELECT);
const [activeTab, setActiveTab] = useState<number>(TabID.TEMPLATE);
const [selected, setSelected] = useState<number | null>(null);
const { items: prompts } = useAvailableTemplatesSuspense();
return (
<ModalView header='Генератор запросом LLM' className='w-100 sm:w-160 px-6'>
<Tabs selectedIndex={activeTab} onSelect={index => setActiveTab(index as TabID)}>
<TabList className='mb-3 mx-auto w-fit flex border divide-x rounded-none'>
<TabLabel label='Шаблон' />
<TabLabel label='Результат' disabled={!selected} />
<TabLabel label='Переменные' disabled={!selected} />
</TabList>
<div className='h-120 flex flex-col gap-2'>
<ModalView header='Генератор запросом LLM' className='w-100 sm:w-160 px-6 flex flex-col h-120'>
<ComboBox
id='prompt-select'
items={prompts}
@ -45,12 +27,8 @@ export function DlgAIPromptDialog() {
className='w-full'
/>
<Suspense fallback={<Loader />}>
<TabPanel>{selected ? <TabPromptSelect promptID={selected} /> : null}</TabPanel>
<TabPanel>{selected ? <TabPromptResult promptID={selected} /> : null}</TabPanel>
<TabPanel>{selected ? <TabPromptVariables promptID={selected} /> : null}</TabPanel>
{selected ? <AIPromptTabs promptID={selected} activeTab={activeTab} setActiveTab={setActiveTab} /> : null}
</Suspense>
</div>
</Tabs>
</ModalView>
);
}

View File

@ -0,0 +1,54 @@
'use client';
import { toast } from 'react-toastify';
import { urls, useConceptNavigation } from '@/app';
import { MiniButton } from '@/components/control';
import { IconClone, IconEdit2 } from '@/components/icons';
import { useDialogsStore } from '@/stores/dialogs';
import { infoMsg } from '@/utils/labels';
import { PromptTabID } from '../../pages/prompt-templates-page/templates-tabs';
interface MenuAIPromptProps {
promptID: number;
generatedPrompt: string;
}
export function MenuAIPrompt({ promptID, generatedPrompt }: MenuAIPromptProps) {
const router = useConceptNavigation();
const hideDialog = useDialogsStore(state => state.hideDialog);
function navigatePrompt() {
hideDialog();
router.push({ path: urls.prompt_template(promptID, PromptTabID.EDIT) });
}
function handleCopyPrompt() {
void navigator.clipboard.writeText(generatedPrompt);
toast.success(infoMsg.promptReady);
}
return (
<div className='flex border-r-2 pr-2'>
<MiniButton
title='Редактировать шаблон'
noHover
noPadding
icon={<IconEdit2 size='1.25rem' />}
className='h-full pl-2 text-muted-foreground hover:text-primary cc-animate-color bg-transparent'
onClick={navigatePrompt}
/>
<MiniButton
title='Скопировать результат в буфер обмена'
noHover
noPadding
icon={<IconClone size='1.25rem' />}
className='h-full pl-2 text-muted-foreground hover:text-constructive cc-animate-color bg-transparent'
onClick={handleCopyPrompt}
disabled={!generatedPrompt}
/>
</div>
);
}

View File

@ -0,0 +1,34 @@
import { TextArea } from '@/components/input';
interface TabPromptEditProps {
label: string;
description: string;
text: string;
setText: (value: string) => void;
}
export function TabPromptEdit({ label, description, text, setText }: TabPromptEditProps) {
return (
<div className='cc-column'>
<div className='flex flex-col gap-2'>
<TextArea
id='prompt-label'
label='Название' //
value={label}
disabled
noResize
rows={1}
/>
<TextArea id='prompt-description' label='Описание' value={description} disabled noResize rows={3} />
<TextArea
id='prompt-text' //
label='Текст шаблона'
value={text}
onChange={event => setText(event.target.value)}
noResize
rows={8}
/>
</div>
</div>
);
}

View File

@ -1,65 +1,17 @@
import { useMemo } from 'react';
import { toast } from 'react-toastify';
import { MiniButton } from '@/components/control';
import { IconClone } from '@/components/icons';
import { TextArea } from '@/components/input';
import { infoMsg } from '@/utils/labels';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
import { PromptVariableType } from '../../models/prompting';
import { extractPromptVariables } from '../../models/prompting-api';
import { evaluatePromptVariable, useAIStore } from '../../stores/ai-context';
interface TabPromptResultProps {
promptID: number;
prompt: string;
}
export function TabPromptResult({ promptID }: TabPromptResultProps) {
const { promptTemplate } = usePromptTemplateSuspense(promptID);
const context = useAIStore();
const variables = useMemo(() => {
return promptTemplate ? extractPromptVariables(promptTemplate.text) : [];
}, [promptTemplate]);
const generatedMessage = (() => {
if (!promptTemplate) {
return '';
}
let result = promptTemplate.text;
for (const variable of variables) {
const type = Object.values(PromptVariableType).find(t => t === variable);
let value = '';
if (type) {
value = evaluatePromptVariable(type, context) ?? '';
}
result = result.replace(new RegExp(`\{\{${variable}\}\}`, 'g'), value || `${variable}`);
}
return result;
})();
function handleCopyPrompt() {
void navigator.clipboard.writeText(generatedMessage);
toast.success(infoMsg.promptReady);
}
export function TabPromptResult({ prompt }: TabPromptResultProps) {
return (
<div className='relative'>
<MiniButton
title='Скопировать в буфер обмена'
className='absolute -top-23 left-0'
icon={<IconClone size='1.25rem' className='icon-green' />}
onClick={handleCopyPrompt}
disabled={!generatedMessage}
/>
<TextArea
aria-label='Сгенерированное сообщение'
value={generatedMessage}
value={prompt}
placeholder='Текст шаблона пуст'
disabled
fitContent
className='w-full max-h-100 min-h-12'
className='w-full h-100'
/>
</div>
);
}

View File

@ -1,44 +0,0 @@
import { TextArea } from '@/components/input';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
interface TabPromptSelectProps {
promptID: number;
}
export function TabPromptSelect({ promptID }: TabPromptSelectProps) {
const { promptTemplate } = usePromptTemplateSuspense(promptID);
return (
<div className='cc-column'>
{promptTemplate && (
<div className='flex flex-col gap-2'>
<TextArea
id='prompt-label'
label='Название' //
value={promptTemplate.label}
disabled
noResize
rows={1}
/>
<TextArea
id='prompt-description'
label='Описание'
value={promptTemplate.description}
disabled
noResize
rows={3}
/>
<TextArea
id='prompt-text' //
label='Текст шаблона'
value={promptTemplate.text}
disabled
noResize
rows={6}
/>
</div>
)}
</div>
);
}

View File

@ -1,18 +1,16 @@
'use client';
import { usePromptTemplateSuspense } from '../../backend/use-prompt-template';
import { describePromptVariable } from '../../labels';
import { PromptVariableType } from '../../models/prompting';
import { extractPromptVariables } from '../../models/prompting-api';
import { useAvailableVariables } from '../../stores/use-available-variables';
interface TabPromptVariablesProps {
promptID: number;
template: string;
}
export function TabPromptVariables({ promptID }: TabPromptVariablesProps) {
const { promptTemplate } = usePromptTemplateSuspense(promptID);
const variables = extractPromptVariables(promptTemplate.text);
export function TabPromptVariables({ template }: TabPromptVariablesProps) {
const variables = extractPromptVariables(template);
const availableTypes = useAvailableVariables();
return (
<ul>

View File

@ -4,14 +4,18 @@ const describePromptVariableRecord: Record<PromptVariableType, string> = {
[PromptVariableType.BLOCK]: 'Текущий блок операционной схемы',
[PromptVariableType.OSS]: 'Текущая операционная схема',
[PromptVariableType.SCHEMA]: 'Текущая концептуальная схема',
[PromptVariableType.CONSTITUENTA]: 'Текущая конституента'
[PromptVariableType.SCHEMA_THESAURUS]: 'Термины и определения текущей концептуальной схемы',
[PromptVariableType.CONSTITUENTA]: 'Текущая конституента',
[PromptVariableType.CONSTITUENTA_SYNTAX_TREE]: 'Синтаксическое дерево конституенты'
};
const mockPromptVariableRecord: Record<PromptVariableType, string> = {
[PromptVariableType.BLOCK]: 'Пример: Текущий блок операционной схемы',
[PromptVariableType.OSS]: 'Пример: Текущая операционная схема',
[PromptVariableType.SCHEMA]: 'Пример: Текущая концептуальная схема',
[PromptVariableType.CONSTITUENTA]: 'Пример: Текущая конституента'
[PromptVariableType.SCHEMA_THESAURUS]: 'Пример\nТермин1 - Определение1\nТермин2 - Определение2',
[PromptVariableType.CONSTITUENTA]: 'Пример: Текущая конституента',
[PromptVariableType.CONSTITUENTA_SYNTAX_TREE]: 'Пример синтаксического дерева конституенты'
};
/** Retrieves description for {@link PromptVariableType}. */

View File

@ -1,3 +1,10 @@
import { type IBlock, type IOperationSchema, NodeType } from '@/features/oss/models/oss';
import { CstType, type IConstituenta, type IRSForm } from '@/features/rsform';
import { labelCstTypification } from '@/features/rsform/labels';
import { isBasicConcept } from '@/features/rsform/models/rsform-api';
import { PARAMETER } from '@/utils/constants';
import { mockPromptVariable } from '../labels';
/** Extracts a list of variables (as string[]) from a target string.
@ -27,3 +34,94 @@ export function generateSample(target: string): string {
}
return result;
}
/** Generates a prompt for a schema variable. */
export function varSchema(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Понятия:\n';
schema.items.forEach(item => {
result += `${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
item.definition_formal
}" - "${item.definition_resolved}" - "${item.convention}"\n`;
});
return result;
}
/** Generates a prompt for a schema thesaurus variable. */
export function varSchemaThesaurus(schema: IRSForm): string {
let result = `Название концептуальной схемы: ${schema.title}\n`;
result += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
result += 'Термины:\n';
schema.items.forEach(item => {
if (item.cst_type === CstType.AXIOM || item.cst_type === CstType.THEOREM) {
return;
}
if (isBasicConcept(item.cst_type)) {
result += `${item.term_resolved} - "${item.convention}"\n`;
} else {
result += `${item.term_resolved} - "${item.definition_resolved}"\n`;
}
});
return result;
}
/** Generates a prompt for a OSS variable. */
export function varOSS(oss: IOperationSchema): string {
let result = `Название операционной схемы: ${oss.title}\n`;
result += `Сокращение: ${oss.alias}\n`;
result += `Описание: ${oss.description}\n`;
result += `Блоки: ${oss.blocks.length}\n`;
oss.hierarchy.topologicalOrder().forEach(blockID => {
const block = oss.itemByNodeID.get(blockID);
if (block?.nodeType !== NodeType.BLOCK) {
return;
}
result += `\nБлок ${block.id}: ${block.title}\n`;
result += `Описание: ${block.description}\n`;
result += `Предок: "${block.parent}"\n`;
});
result += `Операции: ${oss.operations.length}\n`;
oss.operations.forEach(operation => {
result += `\nОперация ${operation.id}: ${operation.alias}\n`;
result += `Название: ${operation.title}\n`;
result += `Описание: ${operation.description}\n`;
result += `Блок: ${operation.parent}\n`;
});
return result;
}
/** Generates a prompt for a block variable. */
export function varBlock(target: IBlock, oss: IOperationSchema): string {
const blocks = oss.blocks.filter(block => block.parent === target.id);
const operations = oss.operations.filter(operation => operation.parent === target.id);
let result = `Название блока: ${target.title}\n`;
result += `Описание: "${target.description}"\n`;
result += '\nСодержание\n';
result += `Блоки: ${blocks.length}\n`;
blocks.forEach(block => {
result += `\nБлок ${block.id}: ${block.title}\n`;
result += `Описание: "${block.description}"\n`;
});
result += `Операции: ${operations.length}\n`;
operations.forEach(operation => {
result += `\nОперация ${operation.id}: ${operation.alias}\n`;
result += `Название: "${operation.title}"\n`;
result += `Описание: "${operation.description}"\n`;
});
return result;
}
/** Generates a prompt for a constituenta variable. */
export function varConstituenta(cst: IConstituenta): string {
return JSON.stringify(cst, null, PARAMETER.indentJSON);
}
/** Generates a prompt for a constituenta syntax tree variable. */
export function varSyntaxTree(cst: IConstituenta): string {
let result = `Конституента: ${cst.alias}\n`;
result += `Формальное выражение: ${cst.definition_formal}\n`;
result += `Дерево синтаксического разбора:\n`;
result += JSON.stringify(cst.parse.syntaxTree, null, PARAMETER.indentJSON);
return result;
}

View File

@ -1,29 +1,15 @@
/** Represents prompt variable type. */
export const PromptVariableType = {
BLOCK: 'block',
// BLOCK_TITLE: 'block.title',
// BLOCK_DESCRIPTION: 'block.description',
// BLOCK_CONTENTS: 'block.contents',
OSS: 'oss',
// OSS_CONTENTS: 'oss.contents',
// OSS_ALIAS: 'oss.alias',
// OSS_TITLE: 'oss.title',
// OSS_DESCRIPTION: 'oss.description',
SCHEMA: 'schema',
// SCHEMA_ALIAS: 'schema.alias',
// SCHEMA_TITLE: 'schema.title',
// SCHEMA_DESCRIPTION: 'schema.description',
// SCHEMA_THESAURUS: 'schema.thesaurus',
SCHEMA_THESAURUS: 'schema.thesaurus',
// SCHEMA_GRAPH: 'schema.graph',
// SCHEMA_TYPE_GRAPH: 'schema.type-graph',
CONSTITUENTA: 'constituenta'
// CONSTITUENTA_ALIAS: 'constituent.alias',
// CONSTITUENTA_CONVENTION: 'constituent.convention',
// CONSTITUENTA_DEFINITION: 'constituent.definition',
// CONSTITUENTA_DEFINITION_FORMAL: 'constituent.definition-formal',
// CONSTITUENTA_EXPRESSION_TREE: 'constituent.expression-tree'
CONSTITUENTA: 'constituenta',
CONSTITUENTA_SYNTAX_TREE: 'constituent.ast'
} as const;
export type PromptVariableType = (typeof PromptVariableType)[keyof typeof PromptVariableType];

View File

@ -2,9 +2,16 @@ import { create } from 'zustand';
import { type IBlock, type IOperationSchema } from '@/features/oss/models/oss';
import { type IConstituenta, type IRSForm } from '@/features/rsform';
import { labelCstTypification } from '@/features/rsform/labels';
import { PromptVariableType } from '../models/prompting';
import {
varBlock,
varConstituenta,
varOSS,
varSchema,
varSchemaThesaurus,
varSyntaxTree
} from '../models/prompting-api';
interface AIContextStore {
currentOSS: IOperationSchema | null;
@ -40,10 +47,12 @@ export function makeVariableSelector(variableType: PromptVariableType) {
case PromptVariableType.OSS:
return (state: AIContextStore) => ({ currentOSS: state.currentOSS });
case PromptVariableType.SCHEMA:
case PromptVariableType.SCHEMA_THESAURUS:
return (state: AIContextStore) => ({ currentSchema: state.currentSchema });
case PromptVariableType.BLOCK:
return (state: AIContextStore) => ({ currentBlock: state.currentBlock });
return (state: AIContextStore) => ({ currentBlock: state.currentBlock, currentOSS: state.currentOSS });
case PromptVariableType.CONSTITUENTA:
case PromptVariableType.CONSTITUENTA_SYNTAX_TREE:
return (state: AIContextStore) => ({ currentConstituenta: state.currentConstituenta });
default:
return () => ({});
@ -54,25 +63,18 @@ export function makeVariableSelector(variableType: PromptVariableType) {
export function evaluatePromptVariable(variableType: PromptVariableType, context: Partial<AIContextStore>): string {
switch (variableType) {
case PromptVariableType.OSS:
return context.currentOSS?.title ?? '';
return context.currentOSS ? varOSS(context.currentOSS) : `!${variableType}!`;
case PromptVariableType.SCHEMA:
return context.currentSchema ? generateSchemaPrompt(context.currentSchema) : '';
return context.currentSchema ? varSchema(context.currentSchema) : `!${variableType}!`;
case PromptVariableType.SCHEMA_THESAURUS:
return context.currentSchema ? varSchemaThesaurus(context.currentSchema) : `!${variableType}!`;
case PromptVariableType.BLOCK:
return context.currentBlock?.title ?? '';
return context.currentBlock && context.currentOSS
? varBlock(context.currentBlock, context.currentOSS)
: `!${variableType}!`;
case PromptVariableType.CONSTITUENTA:
return context.currentConstituenta?.alias ?? '';
return context.currentConstituenta ? varConstituenta(context.currentConstituenta) : `!${variableType}!`;
case PromptVariableType.CONSTITUENTA_SYNTAX_TREE:
return context.currentConstituenta ? varSyntaxTree(context.currentConstituenta) : `!${variableType}!`;
}
}
// ====== Internals =========
function generateSchemaPrompt(schema: IRSForm): string {
let body = `Название концептуальной схемы: ${schema.title}\n`;
body += `[${schema.alias}] Описание: "${schema.description}"\n\n`;
body += 'Понятия:\n';
schema.items.forEach(item => {
body += `${item.alias} - "${labelCstTypification(item)}" - "${item.term_resolved}" - "${
item.definition_formal
}" - "${item.definition_resolved}" - "${item.convention}"\n`;
});
return body;
}

View File

@ -16,7 +16,7 @@ import { promptText } from '@/utils/labels';
import { OperationType } from '../../backend/types';
import { useOssSuspense } from '../../backend/use-oss';
import { type IOperation } from '../../models/oss';
import { type IOperation, NodeType } from '../../models/oss';
import { OssEditContext, type OssTabID } from './oss-edit-context';
@ -32,6 +32,7 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
const setSearchLocation = useLibrarySearchStore(state => state.setLocation);
const searchLocation = useLibrarySearchStore(state => state.location);
const setCurrentOSS = useAIStore(state => state.setCurrentOSS);
const setCurrentBlock = useAIStore(state => state.setCurrentBlock);
const { user } = useAuthSuspense();
const { schema } = useOssSuspense({ itemID: itemID });
@ -57,6 +58,15 @@ export const OssEditState = ({ itemID, children }: React.PropsWithChildren<OssEd
return () => setCurrentOSS(null);
}, [schema, setCurrentOSS]);
useEffect(() => {
const selectedBlock = selectedItems.find(item => item.nodeType === NodeType.BLOCK);
if (selectedBlock) {
setCurrentBlock(selectedBlock);
return () => setCurrentBlock(null);
}
setCurrentBlock(null);
}, [selectedItems, setCurrentBlock]);
function navigateTab(tab: OssTabID) {
const url = urls.oss_props({
id: schema.id,

View File

@ -21,6 +21,8 @@ export const PARAMETER = {
graphNodePadding: 5, // Padding inside graph nodes (in pixels)
graphNodeRadius: 20, // Radius of graph nodes (in pixels)
indentJSON: 2, // Number of spaces for JSON indentation
logicLabel: 'LOGIC',
errorNodeLabel: '[ERROR]',
exteorVersion: '4.9.7'

View File

@ -5,6 +5,7 @@
import { toast } from 'react-toastify';
import { type AxiosError, type AxiosHeaderValue, type AxiosResponse, isAxiosError } from 'axios';
import { PARAMETER } from './constants';
import { infoMsg, promptText } from './labels';
/**
@ -160,7 +161,7 @@ export function convertToCSV(targetObj: readonly object[]): Blob {
* Convert object or array to JSON Blob.
*/
export function convertToJSON(targetObj: unknown): Blob {
const jsonString = JSON.stringify(targetObj, null, 2);
const jsonString = JSON.stringify(targetObj, null, PARAMETER.indentJSON);
return new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
}