From ea892bc9cb905bf2a757351ec4e3ba9c57a12760 Mon Sep 17 00:00:00 2001 From: Ivan <8611739+IRBorisov@users.noreply.github.com> Date: Tue, 22 Jul 2025 20:38:08 +0300 Subject: [PATCH] F: Implement prompt editor --- .../ai/components/prompt-input/completion.tsx | 24 +++++ .../prompt-input/mark-variables.tsx | 66 ++++++++++++++ .../components/prompt-input/no-spellcheck.tsx | 45 ++++++++++ .../prompt-input/parse/parser.terms.ts | 5 +- .../prompt-input/parse/parser.test.ts | 14 +-- .../components/prompt-input/parse/parser.ts | 22 ++--- .../prompt-input/parse/prompt-text.grammar | 4 +- .../components/prompt-input/prompt-input.tsx | 39 +++++---- .../ai/components/prompt-input/tooltip.ts | 87 +++++++++++++++++++ .../dialogs/dlg-ai-prompt/tab-prompt-edit.tsx | 12 ++- .../src/features/ai/models/prompting.ts | 10 +-- .../form-prompt-template.tsx | 29 +++++-- .../tab-view-variables.tsx | 6 +- rsconcept/frontend/src/styling/overrides.css | 10 +++ 14 files changed, 314 insertions(+), 59 deletions(-) create mode 100644 rsconcept/frontend/src/features/ai/components/prompt-input/completion.tsx create mode 100644 rsconcept/frontend/src/features/ai/components/prompt-input/mark-variables.tsx create mode 100644 rsconcept/frontend/src/features/ai/components/prompt-input/no-spellcheck.tsx create mode 100644 rsconcept/frontend/src/features/ai/components/prompt-input/tooltip.ts diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/completion.tsx b/rsconcept/frontend/src/features/ai/components/prompt-input/completion.tsx new file mode 100644 index 00000000..0da01a01 --- /dev/null +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/completion.tsx @@ -0,0 +1,24 @@ +import { type CompletionContext } from '@codemirror/autocomplete'; + +import { describePromptVariable } from '../../labels'; +import { type PromptVariableType } from '../../models/prompting'; + +export function variableCompletions(variables: string[]) { + return (context: CompletionContext) => { + let word = context.matchBefore(/\{\{[a-zA-Z.-]*/); + if (!word && context.explicit) { + word = { from: context.pos, to: context.pos, text: '' }; + } + if (!word || (word.from == word.to && !context.explicit)) { + return null; + } + return { + from: word.from, + to: word.to, + options: variables.map(name => ({ + label: `{{${name}}}`, + info: describePromptVariable(name as PromptVariableType) + })) + }; + }; +} diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/mark-variables.tsx b/rsconcept/frontend/src/features/ai/components/prompt-input/mark-variables.tsx new file mode 100644 index 00000000..df2c211a --- /dev/null +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/mark-variables.tsx @@ -0,0 +1,66 @@ +import { syntaxTree } from '@codemirror/language'; +import { RangeSetBuilder } from '@codemirror/state'; +import { Decoration, type DecorationSet, type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; + +const invalidVarMark = Decoration.mark({ + class: 'text-destructive' +}); + +const validMark = Decoration.mark({ + class: 'text-(--acc-fg-purple)' +}); + +class MarkVariablesPlugin { + decorations: DecorationSet; + allowed: string[]; + + constructor(view: EditorView, allowed: string[]) { + this.allowed = allowed; + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + const tree = syntaxTree(view.state); + const doc = view.state.doc; + + tree.iterate({ + enter: node => { + if (node.name === 'Variable') { + // Extract inner text from the Variable node ({{my_var}}) + const text = doc.sliceString(node.from, node.to); + const match = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/.exec(text); + const varName = match?.[1]; + + if (!varName || !this.allowed.includes(varName)) { + builder.add(node.from, node.to, invalidVarMark); + } else { + builder.add(node.from, node.to, validMark); + } + } + } + }); + + return builder.finish(); + } +} + +/** Returns a ViewPlugin that marks invalid variables in the editor. */ +export function markVariables(allowed: string[]) { + return ViewPlugin.fromClass( + class extends MarkVariablesPlugin { + constructor(view: EditorView) { + super(view, allowed); + } + }, + { + decorations: plugin => plugin.decorations + } + ); +} diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/no-spellcheck.tsx b/rsconcept/frontend/src/features/ai/components/prompt-input/no-spellcheck.tsx new file mode 100644 index 00000000..5257e5d6 --- /dev/null +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/no-spellcheck.tsx @@ -0,0 +1,45 @@ +import { syntaxTree } from '@codemirror/language'; +import { RangeSetBuilder } from '@codemirror/state'; +import { Decoration, type DecorationSet, type EditorView, ViewPlugin, type ViewUpdate } from '@codemirror/view'; + +const noSpellcheckMark = Decoration.mark({ + attributes: { spellcheck: 'false' } +}); + +class NoSpellcheckPlugin { + decorations: DecorationSet; + + constructor(view: EditorView) { + this.decorations = this.buildDecorations(view); + } + + update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged) { + this.decorations = this.buildDecorations(update.view); + } + } + + buildDecorations(view: EditorView): DecorationSet { + const builder = new RangeSetBuilder(); + + const tree = syntaxTree(view.state); + for (const { from, to } of view.visibleRanges) { + tree.iterate({ + from, + to, + enter: node => { + if (node.name === 'Variable') { + builder.add(node.from, node.to, noSpellcheckMark); + } + } + }); + } + + return builder.finish(); + } +} + +/** Plugin that adds a no-spellcheck attribute to all variables in the editor. */ +export const noSpellcheckForVariables = ViewPlugin.fromClass(NoSpellcheckPlugin, { + decorations: (plugin: NoSpellcheckPlugin) => plugin.decorations +}); diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.terms.ts b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.terms.ts index bef36bc7..c4b9399b 100644 --- a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.terms.ts +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.terms.ts @@ -1,6 +1,5 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -export const - Text = 1, +export const Text = 1, Variable = 2, Error = 3, - Filler = 4 + Filler = 4; diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.test.ts b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.test.ts index 73d181ee..16166413 100644 --- a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.test.ts +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.test.ts @@ -12,13 +12,13 @@ const testData = [ ['{{var_1}}', '[Text[Variable]]'], ['{{user.name}}', '[Text[Variable]]'], ['!error!', '[Text[Error]]'], - ['word !error! word', '[Text[Filler Error Filler]]'], - ['{{variable}} !error! word', '[Text[Variable Error Filler]]'], - ['word {{variable}}', '[Text[Filler Variable]]'], - ['word {{variable}} !error!', '[Text[Filler Variable Error]]'], - ['{{variable}} word', '[Text[Variable Filler]]'], - ['!err! {{variable}}', '[Text[Error Variable]]'], - ['!err! {{variable}} word', '[Text[Error Variable Filler]]'] + ['word !error! word', '[Text[Filler][Error][Filler]]'], + ['{{variable}} !error! word', '[Text[Variable][Error][Filler]]'], + ['word {{variable}}', '[Text[Filler][Variable]]'], + ['word {{variable}} !error!', '[Text[Filler][Variable][Error]]'], + ['{{variable}} word', '[Text[Variable][Filler]]'], + ['!err! {{variable}}', '[Text[Error][Variable]]'], + ['!err! {{variable}} word', '[Text[Error][Variable][Filler]]'] ] as const; /** Test prompt grammar parser with various prompt inputs */ diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.ts b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.ts index d4363a4d..1aec37da 100644 --- a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.ts +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/parser.ts @@ -1,18 +1,20 @@ // This file was generated by lezer-generator. You probably shouldn't edit it. -import {LRParser} from "@lezer/lr" -import {highlighting} from "./highlight" +import { LRParser } from '@lezer/lr'; +import { highlighting } from './highlight'; export const parser = LRParser.deserialize({ version: 14, - states: "!vQVQPOOObQPO'#C^OgQPO'#CjO]QPO'#C_OOQO'#C`'#C`OOQO'#Ce'#CeOOQO'#Ca'#CaQVQPOOOuQPO,58xOOQO,59U,59UOzQPO,58yOOQO-E6_-E6_OOQO1G.d1G.dOOQO1G.e1G.e", - stateData: "!U~OWOS~OZPO]RO_QO~O[WO~O_QOU^XZ^X]^X~OY[O~O]]O~O[_W_~", - goto: "x_PP```dPPPjPPPPnTTOVQVORZVTUOVSSOVQXQRYR", - nodeNames: "⚠ Text Variable Error Filler", + states: + "!vQVQPOOObQQO'#C^OgQPO'#CjO]QPO'#C_OOQO'#C`'#C`OOQO'#Ce'#CeOOQO'#Ca'#CaQVQPOOOuQPO,58xOOQO,59U,59UOzQPO,58yOOQO-E6_-E6_OOQO1G.d1G.dOOQO1G.e1G.e", + stateData: '!S~OWOS~OZPO]RO_QO~O[WO~O_QOU^XZ^X]^X~OY[O~O]]O~O_W~', + goto: 'x_PP```dPPPjPPPPnTTOVQVORZVTUOVSSOVQXQRYR', + nodeNames: '⚠ Text Variable Error Filler', maxTerm: 15, propSources: [highlighting], skippedNodes: [0], repeatNodeCount: 1, - tokenData: ")O~RrOX#]XZ$QZ^$u^p#]pq$Qqr&qr!O#]!P!b#]!c!}&v!}#R#]#R#S&v#S#T#]#T#o&v#o#p(h#q#r(s#r#y#]#y#z$u#z$f#]$f$g$u$g#BY#]#BY#BZ$u#BZ$IS#]$IS$I_$u$I_$I|#]$I|$JO$u$JO$JT#]$JT$JU$u$JU$KV#]$KV$KW$u$KW&FU#]&FU&FV$u&FV;'S#];'S;=`#z<%lO#]~#bW_~OX#]Zp#]r!O#]!P!b#]!c#o#]#r;'S#];'S;=`#z<%lO#]~#}P;=`<%l#]~$VYW~X^$Qpq$Q#y#z$Q$f$g$Q#BY#BZ$Q$IS$I_$Q$I|$JO$Q$JT$JU$Q$KV$KW$Q&FU&FV$Q~$|k_~W~OX#]XZ$QZ^$u^p#]pq$Qr!O#]!P!b#]!c#o#]#r#y#]#y#z$u#z$f#]$f$g$u$g#BY#]#BY#BZ$u#BZ$IS#]$IS$I_$u$I_$I|#]$I|$JO$u$JO$JT#]$JT$JU$u$JU$KV#]$KV$KW$u$KW&FU#]&FU&FV$u&FV;'S#];'S;=`#z<%lO#]~&vO]~~&}`[~_~OX#]Zp#]r}#]}!O&v!O!P(P!P!Q#]!Q![&v![!b#]!c!}&v!}#R#]#R#S&v#S#T#]#T#o&v#r;'S#];'S;=`#z<%lO#]~(UU[~}!O(P!O!P(P!Q![(P!c!}(P#R#S(P#T#o(P~(kP#o#p(n~(sOZ~~(vP#q#r(y~)OOY~", - tokenizers: [0], - topRules: {"Text":[0,1]}, + tokenData: + "(^~RqOX#YXZ#zZ^$o^p#Ypq#zqr&hr!b#Y!c!}&m!}#R#Y#R#S&m#S#T#Y#T#o&m#o#p'v#q#r(R#r#y#Y#y#z$o#z$f#Y$f$g$o$g#BY#Y#BY#BZ$o#BZ$IS#Y$IS$I_$o$I_$I|#Y$I|$JO$o$JO$JT#Y$JT$JU$o$JU$KV#Y$KV$KW$o$KW&FU#Y&FU&FV$o&FV;'S#Y;'S;=`#t<%lO#YP#_V_POX#YZp#Yr!b#Y!c#o#Y#r;'S#Y;'S;=`#t<%lO#YP#wP;=`<%l#Y~$PYW~X^#zpq#z#y#z#z$f$g#z#BY#BZ#z$IS$I_#z$I|$JO#z$JT$JU#z$KV$KW#z&FU&FV#z~$vj_PW~OX#YXZ#zZ^$o^p#Ypq#zr!b#Y!c#o#Y#r#y#Y#y#z$o#z$f#Y$f$g$o$g#BY#Y#BY#BZ$o#BZ$IS#Y$IS$I_$o$I_$I|#Y$I|$JO$o$JO$JT#Y$JT$JU$o$JU$KV#Y$KV$KW$o$KW&FU#Y&FU&FV$o&FV;'S#Y;'S;=`#t<%lO#Y~&mO]~R&t`[Q_POX#YZp#Yr}#Y}!O&m!O!P&m!P!Q#Y!Q![&m![!b#Y!c!}&m!}#R#Y#R#S&m#S#T#Y#T#o&m#r;'S#Y;'S;=`#t<%lO#Y~'yP#o#p'|~(ROZ~~(UP#q#r(X~(^OY~", + tokenizers: [0, 1], + topRules: { Text: [0, 1] }, tokenPrec: 47 -}) +}); diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/prompt-text.grammar b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/prompt-text.grammar index 6bd4b32c..8b75a06d 100644 --- a/rsconcept/frontend/src/features/ai/components/prompt-input/parse/prompt-text.grammar +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/parse/prompt-text.grammar @@ -12,9 +12,9 @@ @tokens { space { @whitespace+ } variable { $[a-zA-Z_]$[a-zA-Z0-9_.-]* } - word { ![@{|}!.\t\n ]+ } + word { ![@{|}! \t\n]+ } - @precedence { variable, word, space } + @precedence { word, space } } textItem { diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/prompt-input.tsx b/rsconcept/frontend/src/features/ai/components/prompt-input/prompt-input.tsx index ead3174e..450f19a4 100644 --- a/rsconcept/frontend/src/features/ai/components/prompt-input/prompt-input.tsx +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/prompt-input.tsx @@ -1,6 +1,7 @@ 'use client'; import { forwardRef, useRef } from 'react'; +import { autocompletion } from '@codemirror/autocomplete'; import { type Extension } from '@codemirror/state'; import { tags } from '@lezer/highlight'; import { createTheme } from '@uiw/codemirror-themes'; @@ -15,11 +16,14 @@ import { EditorView } from 'codemirror'; import { Label } from '@/components/input'; import { usePreferencesStore } from '@/stores/preferences'; import { APP_COLORS } from '@/styling/colors'; -import { notImplemented } from '@/utils/utils'; -import { useAvailableVariables } from '../../stores/use-available-variables'; +import { PromptVariableType } from '../../models/prompting'; +import { variableCompletions } from './completion'; +import { markVariables } from './mark-variables'; +import { noSpellcheckForVariables } from './no-spellcheck'; import { PromptLanguage } from './parse'; +import { variableHoverTooltip } from './tooltip'; const EDITOR_OPTIONS: BasicSetupOptions = { highlightSpecialChars: false, @@ -65,6 +69,7 @@ interface PromptInputProps > { value: string; onChange: (newValue: string) => void; + availableVariables?: string[]; label?: string; disabled?: boolean; @@ -83,8 +88,6 @@ export const PromptInput = forwardRef( ref ) => { const darkMode = usePreferencesStore(state => state.darkMode); - const availableVariables = useAvailableVariables(); - console.log(availableVariables); const internalRef = useRef(null); const thisRef = !ref || typeof ref === 'function' ? internalRef : ref; @@ -99,29 +102,28 @@ export const PromptInput = forwardRef( caret: APP_COLORS.fgDefault }, styles: [ - { tag: tags.name, color: APP_COLORS.fgPurple, cursor: 'default' }, // Variable + { tag: tags.name, cursor: 'default' }, // Variable { tag: tags.comment, color: APP_COLORS.fgRed } // Error ] }); + const variables = restProps.availableVariables ?? Object.values(PromptVariableType); + const autoCompleter = autocompletion({ + override: [variableCompletions(variables)], + activateOnTyping: true, + icons: false + }); + const editorExtensions = [ EditorView.lineWrapping, EditorView.contentAttributes.of({ spellcheck: 'true' }), - PromptLanguage + PromptLanguage, + variableHoverTooltip(variables), + autoCompleter, + noSpellcheckForVariables, + markVariables(variables) ]; - function handleInput(event: React.KeyboardEvent) { - if (!thisRef.current?.view) { - event.preventDefault(); - event.stopPropagation(); - return; - } - if ((event.ctrlKey || event.metaKey) && event.code === 'Space') { - notImplemented(); - return; - } - } - return (
diff --git a/rsconcept/frontend/src/features/ai/components/prompt-input/tooltip.ts b/rsconcept/frontend/src/features/ai/components/prompt-input/tooltip.ts new file mode 100644 index 00000000..be1b1a7b --- /dev/null +++ b/rsconcept/frontend/src/features/ai/components/prompt-input/tooltip.ts @@ -0,0 +1,87 @@ +import { syntaxTree } from '@codemirror/language'; +import { type EditorState, type Extension } from '@codemirror/state'; +import { hoverTooltip, type TooltipView } from '@codemirror/view'; +import clsx from 'clsx'; + +import { findEnvelopingNodes } from '@/utils/codemirror'; + +import { describePromptVariable } from '../../labels'; +import { type PromptVariableType } from '../../models/prompting'; + +import { Variable } from './parse/parser.terms'; + +/** + * Retrieves variable from position in Editor. + */ +function findVariableAt(pos: number, state: EditorState) { + const nodes = findEnvelopingNodes(pos, pos, syntaxTree(state), [Variable]); + if (nodes.length !== 1) { + return undefined; + } + const start = nodes[0].from; + const end = nodes[0].to; + const text = state.doc.sliceString(start, end); + const match = /\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}/.exec(text); + const varName = match?.[1]; + if (!varName) { + return undefined; + } + return { + varName: varName, + start: start, + end: end + }; +} + +const tooltipProducer = (available: string[]) => { + return hoverTooltip((view, pos) => { + const parse = findVariableAt(pos, view.state); + if (!parse) { + return null; + } + + const isAvailable = available.includes(parse.varName); + return { + pos: parse.start, + end: parse.end, + above: false, + create: () => domTooltipVariable(parse.varName, isAvailable) + }; + }); +}; + +export function variableHoverTooltip(available: string[]): Extension { + return [tooltipProducer(available)]; +} + +/** + * Create DOM tooltip for {@link PromptVariableType}. + */ +function domTooltipVariable(varName: string, isAvailable: boolean): TooltipView { + const dom = document.createElement('div'); + dom.className = clsx( + 'max-h-100 max-w-100 min-w-40', + 'dense', + 'px-2 py-1 flex flex-col', + 'rounded-md shadow-md', + 'cc-scroll-y', + 'text-sm bg-card', + 'select-none cursor-auto' + ); + + const header = document.createElement('p'); + header.innerHTML = `Переменная ${varName}`; + dom.appendChild(header); + + const status = document.createElement('p'); + status.className = isAvailable ? 'text-green-700' : 'text-red-700'; + status.innerText = isAvailable ? 'Доступна для использования' : 'Недоступна для использования'; + dom.appendChild(status); + + const desc = document.createElement('p'); + desc.className = ''; + desc.innerText = `Описание: ${describePromptVariable(varName as PromptVariableType)}`; + dom.appendChild(desc); + + return { dom: dom }; +} diff --git a/rsconcept/frontend/src/features/ai/dialogs/dlg-ai-prompt/tab-prompt-edit.tsx b/rsconcept/frontend/src/features/ai/dialogs/dlg-ai-prompt/tab-prompt-edit.tsx index 0a35906c..6fc70157 100644 --- a/rsconcept/frontend/src/features/ai/dialogs/dlg-ai-prompt/tab-prompt-edit.tsx +++ b/rsconcept/frontend/src/features/ai/dialogs/dlg-ai-prompt/tab-prompt-edit.tsx @@ -1,5 +1,8 @@ import { TextArea } from '@/components/input'; +import { PromptInput } from '../../components/prompt-input'; +import { useAvailableVariables } from '../../stores/use-available-variables'; + interface TabPromptEditProps { label: string; description: string; @@ -8,6 +11,7 @@ interface TabPromptEditProps { } export function TabPromptEdit({ label, description, text, setText }: TabPromptEditProps) { + const availableVariables = useAvailableVariables(); return (
@@ -20,13 +24,13 @@ export function TabPromptEdit({ label, description, text, setText }: TabPromptEd rows={1} />