Bläddra i källkod

feat: input variables

Co-Authored-By: Classic298 <27028174+Classic298@users.noreply.github.com>
Timothy Jaeryang Baek 3 månader sedan
förälder
incheckning
9f87a0cf21

+ 157 - 55
src/lib/components/chat/MessageInput.svelte

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

+ 297 - 0
src/lib/components/chat/MessageInput/InputVariablesModal.svelte

@@ -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>

+ 43 - 0
src/lib/components/common/RichTextInput.svelte

@@ -207,6 +207,49 @@
 		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 = () => {
 		if (editor) {
 			editor.view.focus();

+ 1 - 3
src/lib/components/common/Valves/MapSelector.svelte

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