Skip to content

Commit c7eae94

Browse files
authored
Variable suggestions feature for Prompt/Graph Prompt inputs (#438)
* add context-suggestions.test.ts * curly brace button wip * textarea-with-suggestions.tsx wip * polish textarea * replace @ with { * polish * polish * dont trigger before }} * use codemirror for prompts * remove x icon, and fix wrapping with triple curly braces * add highlighting variables * rename to prompt editor * remove placeholder * expandable prompt editor * fix onChange * remove name prop from codemirror * add deps * setup `Add variables` button logic * polish add variables * add tooltip for Expand to full screen * fix open to full screen, was broken due tooltip * add PromptEditorWithAddVariables to add variables tooltip in full screen too * improve context-suggestions * cleanup useImperativeHandle.insertTemplateVariable * remove unneeded ref * add reserved keys to suggestion dropdown * add linting for unknown variables * polish * skip JMESPath Expressions from linting * cleanup tests * add `should return all expected suggestions` test * bootstrap tests * fix handles mixed arrays and objects * assert handles optional and nullable fields * add handles union types * support unions * handles multiple context variables pass * remove `handles empty path` test * Exclude arrays from linting, as they are indicated with [*] in the suggestions * fix type check test * add changeset * Update agents-manage-ui/src/components/graph/sidepane/nodes/expandable-text-area.tsx * Update agents-manage-ui/src/components/graph/sidepane/nodes/expandable-text-area.tsx
1 parent 22a8a76 commit c7eae94

File tree

11 files changed

+646
-31
lines changed

11 files changed

+646
-31
lines changed

.changeset/swift-humans-burn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@inkeep/agents-manage-ui": patch
3+
---
4+
5+
Variable suggestions feature for Prompt/Graph Prompt inputs

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,4 @@ dist
6666

6767
.playwright-mcp
6868
.vercel
69+
.idea/

agents-manage-ui/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,8 +40,11 @@
4040
"test:coverage": "vitest --run --coverage"
4141
},
4242
"dependencies": {
43+
"@codemirror/lint": "^6.8.5",
4344
"@ai-sdk/react": "2.0.11",
45+
"@codemirror/autocomplete": "^6.19.0",
4446
"@codemirror/lang-json": "^6.0.2",
47+
"@codemirror/view": "^6.38.4",
4548
"@hookform/resolvers": "^5.2.1",
4649
"@inkeep/agents-core": "workspace:^",
4750
"@inkeep/agents-manage-api": "workspace:^",

agents-manage-ui/src/components/form/expandable-field.tsx

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
DialogTrigger,
1212
} from '@/components/ui/dialog';
1313
import { Label } from '@/components/ui/label';
14+
import { TooltipContent, Tooltip, TooltipTrigger } from '@/components/ui/tooltip';
1415

1516
interface ExpandableFieldProps {
1617
name: string;
@@ -46,17 +47,22 @@ export function ExpandableField({
4647
</div>
4748
<div className="relative">
4849
{compactView}
49-
<DialogTrigger asChild>
50-
<Button
51-
variant="ghost"
52-
size="icon"
53-
className="absolute bottom-2.5 right-2.5 h-6 w-6 hover:text-foreground transition-all backdrop-blur-sm bg-white/90 hover:bg-white/95 dark:bg-card dark:hover:bg-accent border border-border shadow-md dark:shadow-lg"
54-
type="button"
55-
>
56-
<Maximize className="h-4 w-4 text-muted-foreground " />
57-
<span className="sr-only">{expandButtonLabel}</span>
58-
</Button>
59-
</DialogTrigger>
50+
<Tooltip>
51+
<TooltipTrigger asChild>
52+
<DialogTrigger asChild>
53+
<Button
54+
variant="ghost"
55+
size="icon"
56+
className="absolute bottom-2.5 right-2.5 h-6 w-6 hover:text-foreground transition-all backdrop-blur-sm bg-white/90 hover:bg-white/95 dark:bg-card dark:hover:bg-accent border border-border shadow-md dark:shadow-lg"
57+
type="button"
58+
>
59+
<Maximize className="h-4 w-4 text-muted-foreground" />
60+
<span className="sr-only">{expandButtonLabel}</span>
61+
</Button>
62+
</DialogTrigger>
63+
</TooltipTrigger>
64+
<TooltipContent>{expandButtonLabel}</TooltipContent>
65+
</Tooltip>
6066
</div>
6167
</div>
6268
</div>
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
import { type FC, type RefObject, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
2+
import { autocompletion, type CompletionSource, startCompletion } from '@codemirror/autocomplete';
3+
import { Decoration, ViewPlugin, EditorView, type DecorationSet } from '@codemirror/view';
4+
import { linter, type Diagnostic } from '@codemirror/lint';
5+
import { duotoneDark, duotoneLight } from '@uiw/codemirror-theme-duotone';
6+
import CodeMirror, {
7+
type ReactCodeMirrorProps,
8+
type Range,
9+
type ReactCodeMirrorRef,
10+
} from '@uiw/react-codemirror';
11+
import { useTheme } from 'next-themes';
12+
import { cn } from '@/lib/utils';
13+
import { getContextSuggestions } from '@/lib/context-suggestions';
14+
import { useGraphStore } from '@/features/graph/state/use-graph-store';
15+
16+
// Decoration for template variables
17+
const templateVariableDecoration = Decoration.mark({
18+
class: 'cm-template-variable',
19+
});
20+
21+
// Plugin to highlight template variables
22+
const templateVariablePlugin = ViewPlugin.fromClass(
23+
class {
24+
decorations: DecorationSet;
25+
26+
constructor(view: EditorView) {
27+
this.decorations = this.buildDecorations(view);
28+
}
29+
30+
update(update: any) {
31+
if (update.docChanged) {
32+
this.decorations = this.buildDecorations(update.view);
33+
}
34+
}
35+
36+
buildDecorations(view: EditorView): DecorationSet {
37+
const decorations: Range<Decoration>[] = [];
38+
const regex = /\{\{([^}]+)}}/g;
39+
40+
for (let i = 0; i < view.state.doc.lines; i++) {
41+
const line = view.state.doc.line(i + 1);
42+
let match: RegExpExecArray | null;
43+
44+
while ((match = regex.exec(line.text)) !== null) {
45+
const from = line.from + match.index;
46+
const to = line.from + match.index + match[0].length;
47+
decorations.push(templateVariableDecoration.range(from, to));
48+
}
49+
}
50+
51+
return Decoration.set(decorations);
52+
}
53+
},
54+
{
55+
decorations: (v) => v.decorations,
56+
}
57+
);
58+
59+
// Theme for template variables
60+
const templateVariableTheme = EditorView.theme({
61+
'& .cm-template-variable': {
62+
color: '#e67e22', // Orange color for variables
63+
fontWeight: 'bold',
64+
},
65+
'&.cm-dark .cm-template-variable': {
66+
color: '#f39c12', // Lighter orange for dark theme
67+
},
68+
});
69+
70+
const RESERVED_KEYS = new Set(['$time', '$date', '$timestamp', '$now']);
71+
72+
function isJMESPathExpressions(key: string): boolean {
73+
if (key.startsWith('length(')) {
74+
return true;
75+
}
76+
return key.includes('[?') || key.includes('[*]');
77+
}
78+
79+
// Create linter for template variable validation
80+
function createTemplateVariableLinter(suggestions: string[]) {
81+
return linter((view) => {
82+
const diagnostics: Diagnostic[] = [];
83+
const validVariables = new Set(suggestions);
84+
const regex = /\{\{([^}]+)}}/g;
85+
86+
for (let i = 0; i < view.state.doc.lines; i++) {
87+
const line = view.state.doc.line(i + 1);
88+
let match: RegExpExecArray | null;
89+
90+
while ((match = regex.exec(line.text)) !== null) {
91+
const from = line.from + match.index;
92+
const to = line.from + match.index + match[0].length;
93+
const variableName = match[1];
94+
95+
// Check if variable is valid (in suggestions) or reserved
96+
const isValid =
97+
validVariables.has(variableName) ||
98+
RESERVED_KEYS.has(variableName) ||
99+
variableName.startsWith('$env.') ||
100+
// Exclude arrays from linting, as they are indicated with [*] in the suggestions
101+
variableName.includes('[') ||
102+
isJMESPathExpressions(variableName);
103+
104+
if (!isValid) {
105+
diagnostics.push({
106+
from,
107+
to,
108+
severity: 'error',
109+
message: `Unknown variable: ${variableName}`,
110+
});
111+
}
112+
}
113+
}
114+
115+
return diagnostics;
116+
});
117+
}
118+
119+
// Create autocomplete source for context variables
120+
function createContextAutocompleteSource(suggestions: string[]): CompletionSource {
121+
return (context) => {
122+
const { state, pos } = context;
123+
const line = state.doc.lineAt(pos);
124+
const to = pos - line.from;
125+
const textBefore = line.text.slice(0, to);
126+
// Check if we're after a { character
127+
const match = textBefore.match(/\{([^}]*)$/);
128+
if (!match) return null;
129+
130+
const query = match[1].toLowerCase();
131+
const filteredSuggestions = suggestions.filter((s) => s.toLowerCase().includes(query));
132+
const nextChar = line.text[to];
133+
return {
134+
from: pos - match[1].length,
135+
to: pos,
136+
options: ['$env.', ...RESERVED_KEYS, ...filteredSuggestions].map((suggestion) => ({
137+
label: suggestion,
138+
apply: `{${suggestion}${nextChar === '}' ? '}' : '}}'}`, // insert `}}` at the end if next character is not `}`
139+
})),
140+
};
141+
};
142+
}
143+
144+
export interface TextareaWithSuggestionsProps extends Omit<ReactCodeMirrorProps, 'onChange'> {
145+
onChange: (value: string) => void;
146+
placeholder?: string;
147+
disabled?: boolean;
148+
readOnly?: boolean;
149+
ref?: RefObject<{ insertTemplateVariable: () => void }>;
150+
}
151+
152+
function tryJsonParse(json: string): object {
153+
if (!json.trim()) {
154+
return {};
155+
}
156+
try {
157+
return JSON.parse(json);
158+
} catch {}
159+
return {};
160+
}
161+
162+
export const PromptEditor: FC<TextareaWithSuggestionsProps> = ({
163+
className,
164+
value,
165+
onChange,
166+
placeholder,
167+
disabled,
168+
readOnly,
169+
ref,
170+
...rest
171+
}) => {
172+
const editorRef = useRef<ReactCodeMirrorRef | null>(null);
173+
const { resolvedTheme } = useTheme();
174+
const isDark = resolvedTheme === 'dark';
175+
useEffect(() => {
176+
editorRef.current = new EditorView();
177+
}, []);
178+
179+
useImperativeHandle(ref, () => ({
180+
insertTemplateVariable() {
181+
const view = editorRef.current?.view;
182+
if (!view) {
183+
return;
184+
}
185+
const { doc, selection } = view.state;
186+
// If there's a caret, insert at caret; otherwise, fall back to end of the current line.
187+
const insertPos = selection.main.empty ? selection.main.head : doc.line(doc.lines).to;
188+
// Insert "{}" and put the cursor between
189+
view.dispatch({
190+
changes: { from: insertPos, to: insertPos, insert: '{}' },
191+
selection: { anchor: insertPos + 1 },
192+
scrollIntoView: true,
193+
});
194+
startCompletion(view);
195+
},
196+
}));
197+
198+
const contextConfig = useGraphStore((state) => state.metadata.contextConfig);
199+
200+
const extensions = useMemo(() => {
201+
const contextVariables = tryJsonParse(contextConfig.contextVariables);
202+
const requestContextSchema = tryJsonParse(contextConfig.requestContextSchema);
203+
const suggestions = getContextSuggestions({
204+
requestContextSchema,
205+
// @ts-expect-error -- todo: improve type
206+
contextVariables,
207+
});
208+
return [
209+
autocompletion({
210+
override: [createContextAutocompleteSource(suggestions)],
211+
compareCompletions(_a, _b) {
212+
// Disable default localCompare sorting
213+
return -1;
214+
},
215+
}),
216+
templateVariablePlugin,
217+
templateVariableTheme,
218+
createTemplateVariableLinter(suggestions),
219+
];
220+
}, [contextConfig]);
221+
222+
return (
223+
<CodeMirror
224+
ref={editorRef}
225+
{...rest}
226+
value={value || ''}
227+
onChange={onChange}
228+
extensions={extensions}
229+
theme={isDark ? duotoneDark : duotoneLight}
230+
placeholder={placeholder}
231+
editable={!disabled && !readOnly}
232+
basicSetup={{
233+
lineNumbers: false,
234+
foldGutter: false,
235+
highlightActiveLine: false,
236+
}}
237+
data-disabled={disabled ? '' : undefined}
238+
data-read-only={readOnly ? '' : undefined}
239+
className={cn(
240+
'h-full [&>.cm-editor]:max-h-[inherit] [&>.cm-editor]:!bg-transparent dark:[&>.cm-editor]:!bg-input/30 [&>.cm-editor]:!outline-none [&>.cm-editor]:rounded-[7px] [&>.cm-editor]:px-3 [&>.cm-editor]:py-2 leading-2 text-xs font-mono rounded-md border border-input shadow-xs transition-[color,box-shadow] data-disabled:cursor-not-allowed data-disabled:opacity-50 data-disabled:bg-muted data-invalid:border-destructive has-[.cm-focused]:border-ring has-[.cm-focused]:ring-ring/50 has-[.cm-focused]:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
241+
className
242+
)}
243+
/>
244+
);
245+
};

agents-manage-ui/src/components/graph/sidepane/metadata/metadata-editor.tsx

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -148,10 +148,9 @@ function MetadataEditor() {
148148
<div className="space-y-2">
149149
<ExpandableTextArea
150150
id="graph-prompt"
151-
name="graph-prompt"
152151
label="Graph prompt"
153152
value={graphPrompt || ''}
154-
onChange={(e) => updateMetadata('graphPrompt', e.target.value)}
153+
onChange={(value) => updateMetadata('graphPrompt', value)}
155154
placeholder="System-level instructions for this graph..."
156155
className="max-h-96"
157156
/>

agents-manage-ui/src/components/graph/sidepane/nodes/agent-node-editor.tsx

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -135,11 +135,9 @@ export function AgentNodeEditor({
135135

136136
<div className="space-y-2">
137137
<ExpandableTextArea
138-
ref={(el) => setFieldRef('prompt', el)}
139138
id="prompt"
140-
name="prompt"
141139
value={selectedNode.data.prompt || ''}
142-
onChange={(e) => updatePath('prompt', e.target.value)}
140+
onChange={(value) => updatePath('prompt', value)}
143141
placeholder="You are a helpful assistant..."
144142
data-invalid={errorHelpers?.hasFieldError('prompt') ? '' : undefined}
145143
className="w-full max-h-96 data-invalid:border-red-300 data-invalid:focus-visible:border-red-300 data-invalid:focus-visible:ring-red-300"
@@ -159,7 +157,7 @@ export function AgentNodeEditor({
159157
/>
160158
<Separator />
161159
{/* Agent Execution Limits */}
162-
<div className="space-y-8 ">
160+
<div className="space-y-8">
163161
<SectionHeader
164162
title="Execution limits"
165163
description="Configure agent-level execution limits for steps within this agent."

0 commit comments

Comments
 (0)