feat: input variables

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
This commit is contained in:
Timothy Jaeryang Baek 2025-07-05 02:16:44 +04:00
parent 5526b66165
commit 9f87a0cf21
4 changed files with 499 additions and 59 deletions

View file

@ -64,6 +64,7 @@
import Sparkles from '../icons/Sparkles.svelte'; import Sparkles from '../icons/Sparkles.svelte';
import { KokoroWorker } from '$lib/workers/KokoroWorker'; import { KokoroWorker } from '$lib/workers/KokoroWorker';
import InputVariablesModal from './MessageInput/InputVariablesModal.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
export let transparentBackground = false; export let transparentBackground = false;
@ -95,6 +96,10 @@
export let webSearchEnabled = false; export let webSearchEnabled = false;
export let codeInterpreterEnabled = false; export let codeInterpreterEnabled = false;
let showInputVariablesModal = false;
let inputVariables = {};
let inputVariableValues = {};
$: onChange({ $: onChange({
prompt, prompt,
files: files files: files
@ -113,74 +118,62 @@
codeInterpreterEnabled codeInterpreterEnabled
}); });
export const setText = (text?: string) => { const extractInputVariables = (text: string): Record<string, any> => {
const chatInput = document.getElementById('chat-input'); const regex = /{{\s*([^|}\s]+)\s*\|\s*([^}]+)\s*}}/g;
const variables: Record<string, any> = {};
let match;
if (chatInput) { // Use exec() loop instead of matchAll() for better compatibility
if ($settings?.richTextInput ?? true) { while ((match = regex.exec(text)) !== null) {
chatInputElement.setText(text); const varName = match[1].trim();
chatInputElement.focus(); const definition = match[2].trim();
} else { variables[varName] = parseVariableDefinition(definition);
chatInput.value = text;
prompt = text;
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
}
} }
return variables;
}; };
function getWordAtCursor(text, cursor) { const parseVariableDefinition = (definition: string): Record<string, any> => {
if (typeof text !== 'string' || cursor == null) return ''; const [firstPart, ...propertyParts] = definition.split(':');
const left = text.slice(0, cursor);
const right = text.slice(cursor);
const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || '';
const rightWord = right.match(/^([^\s]*)/)?.[1] || ''; // Parse type (explicit or implied)
return leftWord + rightWord; const type = firstPart.startsWith('type=') ? firstPart.slice(5) : firstPart;
}
const getCommand = () => { // Parse properties using reduce
const chatInput = document.getElementById('chat-input'); const properties = propertyParts.reduce((props, part) => {
let word = ''; const [propertyName, ...valueParts] = part.split('=');
const propertyValue = valueParts.join('='); // Handle values with = signs
if (chatInput) { return propertyName && propertyValue
if ($settings?.richTextInput ?? true) { ? {
word = chatInputElement?.getWordAtDocPos(); ...props,
} else { [propertyName.trim()]: parseJsonValue(propertyValue.trim())
const cursor = chatInput ? chatInput.selectionStart : prompt.length; }
word = getWordAtCursor(prompt, cursor); : props;
}, {});
return { type, ...properties };
};
const parseJsonValue = (value: string): any => {
// Check if it starts with square or curly brackets (JSON)
if (/^[\[{]/.test(value)) {
try {
return JSON.parse(value);
} catch {
return value; // Return as string if JSON parsing fails
} }
} }
return word; return value;
}; };
function getWordBoundsAtCursor(text, cursor) {
let start = cursor,
end = cursor;
while (start > 0 && !/\s/.test(text[start - 1])) --start;
while (end < text.length && !/\s/.test(text[end])) ++end;
return { start, end };
}
function replaceCommandWithText(text) {
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
if ($settings?.richTextInput ?? true) {
chatInputElement?.replaceCommandWithText(text);
} else {
const cursor = chatInput.selectionStart;
const { start, end } = getWordBoundsAtCursor(prompt, cursor);
prompt = prompt.slice(0, start) + text + prompt.slice(end);
chatInput.focus();
chatInput.setSelectionRange(start + text.length, start + text.length);
}
}
const inputVariableHandler = async (text: string) => { const inputVariableHandler = async (text: string) => {
return text; inputVariables = extractInputVariables(text);
if (Object.keys(inputVariables).length > 0) {
showInputVariablesModal = true;
}
}; };
const textVariableHandler = async (text: string) => { const textVariableHandler = async (text: string) => {
@ -262,15 +255,116 @@
text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday); text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
} }
text = await inputVariableHandler(text); inputVariableHandler(text);
return text; return text;
}; };
const replaceVariables = (variables: Record<string, any>) => {
console.log('Replacing variables:', variables);
const chatInput = document.getElementById('chat-input');
if (chatInput) {
if ($settings?.richTextInput ?? true) {
chatInputElement.replaceVariables(variables);
chatInputElement.focus();
} else {
// Get current value from the input element
let currentValue = chatInput.value || '';
// Replace template variables using regex
const updatedValue = currentValue.replace(
/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g,
(match, varName) => {
const trimmedVarName = varName.trim();
return variables.hasOwnProperty(trimmedVarName)
? String(variables[trimmedVarName])
: match;
}
);
// Update the input value
chatInput.value = updatedValue;
chatInput.focus();
chatInput.dispatchEvent(new Event('input', { bubbles: true }));
}
}
};
export const setText = async (text?: string) => {
const chatInput = document.getElementById('chat-input');
if (chatInput) {
text = await textVariableHandler(text || '');
if ($settings?.richTextInput ?? true) {
chatInputElement.setText(text);
chatInputElement.focus();
} else {
chatInput.value = text;
prompt = text;
chatInput.focus();
chatInput.dispatchEvent(new Event('input'));
}
}
};
const getCommand = () => {
const getWordAtCursor = (text, cursor) => {
if (typeof text !== 'string' || cursor == null) return '';
const left = text.slice(0, cursor);
const right = text.slice(cursor);
const leftWord = left.match(/(?:^|\s)([^\s]*)$/)?.[1] || '';
const rightWord = right.match(/^([^\s]*)/)?.[1] || '';
return leftWord + rightWord;
};
const chatInput = document.getElementById('chat-input');
let word = '';
if (chatInput) {
if ($settings?.richTextInput ?? true) {
word = chatInputElement?.getWordAtDocPos();
} else {
const cursor = chatInput ? chatInput.selectionStart : prompt.length;
word = getWordAtCursor(prompt, cursor);
}
}
return word;
};
const replaceCommandWithText = (text) => {
const getWordBoundsAtCursor = (text, cursor) => {
let start = cursor,
end = cursor;
while (start > 0 && !/\s/.test(text[start - 1])) --start;
while (end < text.length && !/\s/.test(text[end])) ++end;
return { start, end };
};
const chatInput = document.getElementById('chat-input');
if (!chatInput) return;
if ($settings?.richTextInput ?? true) {
chatInputElement?.replaceCommandWithText(text);
} else {
const cursor = chatInput.selectionStart;
const { start, end } = getWordBoundsAtCursor(prompt, cursor);
prompt = prompt.slice(0, start) + text + prompt.slice(end);
chatInput.focus();
chatInput.setSelectionRange(start + text.length, start + text.length);
}
};
const insertTextAtCursor = async (text: string) => { const insertTextAtCursor = async (text: string) => {
const chatInput = document.getElementById('chat-input'); const chatInput = document.getElementById('chat-input');
if (!chatInput) return; if (!chatInput) return;
text = await textVariableHandler(text); text = await textVariableHandler(text);
if (command) { if (command) {
replaceCommandWithText(text); replaceCommandWithText(text);
} else { } else {
@ -731,6 +825,14 @@
<FilesOverlay show={dragged} /> <FilesOverlay show={dragged} />
<ToolServersModal bind:show={showTools} {selectedToolIds} /> <ToolServersModal bind:show={showTools} {selectedToolIds} />
<InputVariablesModal
bind:show={showInputVariablesModal}
variables={inputVariables}
onSave={(variableValues) => {
inputVariableValues = { ...inputVariableValues, ...variableValues };
replaceVariables(inputVariableValues);
}}
/>
{#if loaded} {#if loaded}
<div class="w-full font-primary"> <div class="w-full font-primary">

View file

@ -0,0 +1,297 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { models, config } from '$lib/stores';
import { toast } from 'svelte-sonner';
import { copyToClipboard } from '$lib/utils';
import XMark from '$lib/components/icons/XMark.svelte';
import Modal from '$lib/components/common/Modal.svelte';
import Spinner from '$lib/components/common/Spinner.svelte';
import MapSelector from '$lib/components/common/Valves/MapSelector.svelte';
const i18n = getContext('i18n');
export let show = false;
export let variables = {};
export let onSave = (e) => {};
let loading = false;
let variableValues = {};
const submitHandler = async () => {
onSave(variableValues);
show = false;
};
const init = async () => {
loading = true;
variableValues = {};
for (const variable of Object.keys(variables)) {
if (variables[variable]?.default !== undefined) {
variableValues[variable] = variables[variable].default;
} else {
variableValues[variable] = '';
}
}
loading = false;
};
$: if (show) {
init();
}
</script>
<Modal bind:show size="md">
<div>
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4 pb-2">
<div class=" text-lg font-medium self-center">{$i18n.t('Input Variables')}</div>
<button
class="self-center"
on:click={() => {
show = false;
}}
>
<XMark className={'size-5'} />
</button>
</div>
<div class="flex flex-col md:flex-row w-full px-5 pb-4 md:space-x-4 dark:text-gray-200">
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
<form
class="flex flex-col w-full"
on:submit|preventDefault={() => {
submitHandler();
}}
>
<div class="px-1">
{#if !loading}
<div class="flex flex-col gap-1">
{#each Object.keys(variables) as variable, idx}
<div class=" py-0.5 w-full justify-between">
<div class="flex w-full justify-between mb-1.5">
<div class=" self-center text-xs font-medium">
{variable}
{#if variables[variable]?.required ?? true}
<span class=" text-gray-500">*required</span>
{/if}
</div>
</div>
<div class="flex mt-0.5 mb-0.5 space-x-2">
<div class=" flex-1">
{#if variables[variable]?.type === 'select'}
<select
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
bind:value={variableValues[variable]}
>
{#each variables[variable]?.options ?? [] as option}
<option value={option} selected={option === variableValues[variable]}>
{option}
</option>
{/each}
</select>
{:else if variables[variable]?.type === 'checkbox'}
<div class="flex items-center space-x-2">
<div class="relative size-6 flex justify-center items-center">
<input
type="checkbox"
bind:checked={variableValues[variable]}
class="size-3.5 rounded cursor-pointer border border-gray-200 dark:border-gray-700"
/>
</div>
<input
type="text"
class="flex-1 py-2 text-sm dark:text-gray-300 bg-transparent outline-hidden"
placeholder="Enter checkbox label"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
{:else if variables[variable]?.type === 'color'}
<div class="flex items-center space-x-2">
<div class="relative size-6">
<input
type="color"
class="size-6 rounded cursor-pointer border border-gray-200 dark:border-gray-700"
value={variableValues[variable]}
on:input={(e) => {
// Convert the color value to uppercase immediately
variableValues[variable] = e.target.value.toUpperCase();
}}
/>
</div>
<input
type="text"
class="flex-1 py-2 text-sm dark:text-gray-300 bg-transparent outline-hidden"
placeholder="Enter hex color (e.g. #FF0000)"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
{:else if variables[variable]?.type === 'date'}
<input
type="date"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'datetime-local'}
<input
type="datetime-local"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'email'}
<input
type="email"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'month'}
<input
type="month"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'number'}
<input
type="number"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'range'}
<input
type="range"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'tel'}
<input
type="tel"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'text'}
<input
type="text"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'time'}
<input
type="time"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'url'}
<input
type="url"
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{:else if variables[variable]?.type === 'map'}
<!-- EXPERIMENTAL INPUT TYPE, DO NOT USE IN PRODUCTION -->
<div class="flex flex-col items-center gap-1">
<MapSelector
setViewLocation={((variableValues[variable] ?? '').includes(',') ??
false)
? variableValues[variable].split(',')
: null}
onClick={(value) => {
variableValues[variable] = value;
}}
/>
<input
type="text"
class=" w-full py-1 text-left text-sm dark:text-gray-300 bg-transparent outline-hidden"
placeholder="Enter coordinates (e.g. 51.505, -0.09)"
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
</div>
{:else}
<textarea
class="w-full rounded-lg py-2 px-4 text-sm dark:text-gray-300 dark:bg-gray-850 outline-hidden border border-gray-100 dark:border-gray-850"
placeholder={variables[variable]?.placeholder ?? ''}
bind:value={variableValues[variable]}
autocomplete="off"
required
/>
{/if}
</div>
</div>
<!-- {#if (valvesSpec.properties[property]?.description ?? null) !== null}
<div class="text-xs text-gray-500">
{valvesSpec.properties[property].description}
</div>
{/if} -->
</div>
{/each}
</div>
{:else}
<Spinner className="size-5" />
{/if}
</div>
<div class="flex justify-end pt-3 text-sm font-medium">
<button
class="px-3.5 py-1.5 text-sm font-medium bg-white hover:bg-gray-100 text-black dark:bg-black dark:text-white dark:hover:bg-gray-900 transition rounded-full"
type="button"
on:click={() => {
show = false;
}}
>
{$i18n.t('Cancel')}
</button>
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full"
type="submit"
>
{$i18n.t('Save')}
</button>
</div>
</form>
</div>
</div>
</div>
</Modal>

View file

@ -207,6 +207,49 @@
selectNextTemplate(editor.view.state, editor.view.dispatch); selectNextTemplate(editor.view.state, editor.view.dispatch);
}; };
export const replaceVariables = (variables) => {
if (!editor) return;
const { state, view } = editor;
const { doc } = state;
// Create a transaction to replace variables
let tr = state.tr;
let offset = 0; // Track position changes due to text length differences
// Collect all replacements first to avoid position conflicts
const replacements = [];
doc.descendants((node, pos) => {
if (node.isText && node.text) {
const text = node.text;
const replacedText = text.replace(/{{\s*([^|}]+)(?:\|[^}]*)?\s*}}/g, (match, varName) => {
const trimmedVarName = varName.trim();
return variables.hasOwnProperty(trimmedVarName)
? String(variables[trimmedVarName])
: match;
});
if (replacedText !== text) {
replacements.push({
from: pos,
to: pos + text.length,
text: replacedText
});
}
}
});
// Apply replacements in reverse order to maintain correct positions
replacements.reverse().forEach(({ from, to, text }) => {
tr = tr.replaceWith(from, to, text !== '' ? state.schema.text(text) : []);
});
// Only dispatch if there are changes
if (replacements.length > 0) {
view.dispatch(tr);
}
};
export const focus = () => { export const focus = () => {
if (editor) { if (editor) {
editor.view.focus(); editor.view.focus();

View file

@ -32,9 +32,7 @@
map.fitBounds(markerGroupLayer.getBounds(), { map.fitBounds(markerGroupLayer.getBounds(), {
maxZoom: 13 maxZoom: 13
}); });
} catch (error) { } catch (error) {}
console.error('Error fitting bounds for markers:', error);
}
} }
}; };