Forráskód Böngészése

enh: support commands in note chat

Timothy Jaeryang Baek 3 hónapja
szülő
commit
9632f40335

+ 256 - 16
src/lib/components/channel/MessageInput.svelte

@@ -7,8 +7,18 @@
 
 	const i18n = getContext('i18n');
 
-	import { config, mobile, settings, socket } from '$lib/stores';
-	import { blobToFile, compressImage } from '$lib/utils';
+	import { config, mobile, settings, socket, user } from '$lib/stores';
+	import {
+		blobToFile,
+		compressImage,
+		extractInputVariables,
+		getCurrentDateTime,
+		getFormattedDate,
+		getFormattedTime,
+		getUserPosition,
+		getUserTimezone,
+		getWeekday
+	} from '$lib/utils';
 
 	import Tooltip from '../common/Tooltip.svelte';
 	import RichTextInput from '../common/RichTextInput.svelte';
@@ -19,6 +29,8 @@
 	import FileItem from '../common/FileItem.svelte';
 	import Image from '../common/Image.svelte';
 	import FilesOverlay from '../chat/MessageInput/FilesOverlay.svelte';
+	import Commands from '../chat/MessageInput/Commands.svelte';
+	import InputVariablesModal from '../chat/MessageInput/InputVariablesModal.svelte';
 
 	export let placeholder = $i18n.t('Send a Message');
 	export let transparentBackground = false;
@@ -32,6 +44,8 @@
 	let files = [];
 
 	export let chatInputElement;
+
+	let commandsElement;
 	let filesInputElement;
 	let inputFiles;
 
@@ -47,6 +61,166 @@
 
 	export let acceptFiles = true;
 
+	let showInputVariablesModal = false;
+	let inputVariables: Record<string, any> = {};
+	let inputVariableValues = {};
+
+	const inputVariableHandler = async (text: string) => {
+		inputVariables = extractInputVariables(text);
+		if (Object.keys(inputVariables).length > 0) {
+			showInputVariablesModal = true;
+		}
+	};
+
+	const textVariableHandler = async (text: string) => {
+		if (text.includes('{{CLIPBOARD}}')) {
+			const clipboardText = await navigator.clipboard.readText().catch((err) => {
+				toast.error($i18n.t('Failed to read clipboard contents'));
+				return '{{CLIPBOARD}}';
+			});
+
+			const clipboardItems = await navigator.clipboard.read();
+
+			let imageUrl = null;
+			for (const item of clipboardItems) {
+				// Check for known image types
+				for (const type of item.types) {
+					if (type.startsWith('image/')) {
+						const blob = await item.getType(type);
+						imageUrl = URL.createObjectURL(blob);
+					}
+				}
+			}
+
+			if (imageUrl) {
+				files = [
+					...files,
+					{
+						type: 'image',
+						url: imageUrl
+					}
+				];
+			}
+
+			text = text.replaceAll('{{CLIPBOARD}}', clipboardText);
+		}
+
+		if (text.includes('{{USER_LOCATION}}')) {
+			let location;
+			try {
+				location = await getUserPosition();
+			} catch (error) {
+				toast.error($i18n.t('Location access not allowed'));
+				location = 'LOCATION_UNKNOWN';
+			}
+			text = text.replaceAll('{{USER_LOCATION}}', String(location));
+		}
+
+		if (text.includes('{{USER_NAME}}')) {
+			const name = $user?.name || 'User';
+			text = text.replaceAll('{{USER_NAME}}', name);
+		}
+
+		if (text.includes('{{USER_LANGUAGE}}')) {
+			const language = localStorage.getItem('locale') || 'en-US';
+			text = text.replaceAll('{{USER_LANGUAGE}}', language);
+		}
+
+		if (text.includes('{{CURRENT_DATE}}')) {
+			const date = getFormattedDate();
+			text = text.replaceAll('{{CURRENT_DATE}}', date);
+		}
+
+		if (text.includes('{{CURRENT_TIME}}')) {
+			const time = getFormattedTime();
+			text = text.replaceAll('{{CURRENT_TIME}}', time);
+		}
+
+		if (text.includes('{{CURRENT_DATETIME}}')) {
+			const dateTime = getCurrentDateTime();
+			text = text.replaceAll('{{CURRENT_DATETIME}}', dateTime);
+		}
+
+		if (text.includes('{{CURRENT_TIMEZONE}}')) {
+			const timezone = getUserTimezone();
+			text = text.replaceAll('{{CURRENT_TIMEZONE}}', timezone);
+		}
+
+		if (text.includes('{{CURRENT_WEEKDAY}}')) {
+			const weekday = getWeekday();
+			text = text.replaceAll('{{CURRENT_WEEKDAY}}', weekday);
+		}
+
+		inputVariableHandler(text);
+		return text;
+	};
+
+	const replaceVariables = (variables: Record<string, any>) => {
+		if (!chatInputElement) return;
+		console.log('Replacing variables:', variables);
+
+		chatInputElement.replaceVariables(variables);
+		chatInputElement.focus();
+	};
+
+	export const setText = async (text?: string) => {
+		if (!chatInputElement) return;
+
+		text = await textVariableHandler(text || '');
+
+		chatInputElement?.setText(text);
+		chatInputElement?.focus();
+	};
+
+	const getCommand = () => {
+		if (!chatInputElement) return;
+
+		let word = '';
+		word = chatInputElement?.getWordAtDocPos();
+
+		return word;
+	};
+
+	const replaceCommandWithText = (text) => {
+		if (!chatInputElement) return;
+
+		chatInputElement?.replaceCommandWithText(text);
+	};
+
+	const insertTextAtCursor = async (text: string) => {
+		text = await textVariableHandler(text);
+
+		if (command) {
+			replaceCommandWithText(text);
+		} else {
+			const selection = window.getSelection();
+			if (selection && selection.rangeCount > 0) {
+				const range = selection.getRangeAt(0);
+				range.deleteContents();
+				range.insertNode(document.createTextNode(text));
+				range.collapse(false);
+				selection.removeAllRanges();
+				selection.addRange(range);
+			}
+		}
+
+		await tick();
+		const chatInputContainer = document.getElementById('chat-input-container');
+		if (chatInputContainer) {
+			chatInputContainer.scrollTop = chatInputContainer.scrollHeight;
+		}
+
+		await tick();
+		if (chatInputElement) {
+			chatInputElement.focus();
+		}
+	};
+
+	let command = '';
+
+	export let showCommands = false;
+	$: showCommands = ['/'].includes(command?.charAt(0));
+
 	const screenCaptureHandler = async () => {
 		try {
 			// Request screen media
@@ -355,6 +529,15 @@
 	/>
 {/if}
 
+<InputVariablesModal
+	bind:show={showInputVariablesModal}
+	variables={inputVariables}
+	onSave={(variableValues) => {
+		inputVariableValues = { ...inputVariableValues, ...variableValues };
+		replaceVariables(inputVariableValues);
+	}}
+/>
+
 <div class="bg-transparent">
 	<div
 		class="{($settings?.widescreenMode ?? null)
@@ -403,6 +586,13 @@
 							</div>
 						{/if}
 					</div>
+
+					<Commands
+						bind:this={commandsElement}
+						show={showCommands}
+						{command}
+						insertTextHandler={insertTextAtCursor}
+					/>
 				</div>
 			</div>
 		</div>
@@ -518,27 +708,77 @@
 									onChange={(e) => {
 										const { md } = e;
 										content = md;
+										command = getCommand();
 									}}
 									on:keydown={async (e) => {
 										e = e.detail.event;
 										const isCtrlPressed = e.ctrlKey || e.metaKey; // metaKey is for Cmd key on Mac
-										if (
-											!$mobile ||
-											!(
-												'ontouchstart' in window ||
-												navigator.maxTouchPoints > 0 ||
-												navigator.msMaxTouchPoints > 0
-											)
-										) {
-											// Prevent Enter key from creating a new line
-											// Uses keyCode '13' for Enter key for chinese/japanese keyboards
-											if (e.keyCode === 13 && !e.shiftKey) {
+
+										const commandsContainerElement = document.getElementById('commands-container');
+
+										if (commandsContainerElement) {
+											if (commandsContainerElement && e.key === 'ArrowUp') {
+												e.preventDefault();
+												commandsElement.selectUp();
+
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
+												commandOptionButton.scrollIntoView({ block: 'center' });
+											}
+
+											if (commandsContainerElement && e.key === 'ArrowDown') {
 												e.preventDefault();
+												commandsElement.selectDown();
+
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
+												commandOptionButton.scrollIntoView({ block: 'center' });
 											}
 
-											// Submit the content when Enter key is pressed
-											if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
-												submitHandler();
+											if (commandsContainerElement && e.key === 'Tab') {
+												e.preventDefault();
+
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
+
+												commandOptionButton?.click();
+											}
+
+											if (commandsContainerElement && e.key === 'Enter') {
+												e.preventDefault();
+
+												const commandOptionButton = [
+													...document.getElementsByClassName('selected-command-option-button')
+												]?.at(-1);
+
+												if (commandOptionButton) {
+													commandOptionButton?.click();
+												} else {
+													document.getElementById('send-message-button')?.click();
+												}
+											}
+										} else {
+											if (
+												!$mobile ||
+												!(
+													'ontouchstart' in window ||
+													navigator.maxTouchPoints > 0 ||
+													navigator.msMaxTouchPoints > 0
+												)
+											) {
+												// Prevent Enter key from creating a new line
+												// Uses keyCode '13' for Enter key for chinese/japanese keyboards
+												if (e.keyCode === 13 && !e.shiftKey) {
+													e.preventDefault();
+												}
+
+												// Submit the content when Enter key is pressed
+												if (content !== '' && e.keyCode === 13 && !e.shiftKey) {
+													submitHandler();
+												}
 											}
 										}
 

+ 1 - 118
src/lib/components/chat/MessageInput.svelte

@@ -31,6 +31,7 @@
 		compressImage,
 		createMessagesList,
 		extractCurlyBraceWords,
+		extractInputVariables,
 		getCurrentDateTime,
 		getFormattedDate,
 		getFormattedTime,
@@ -118,124 +119,6 @@
 		codeInterpreterEnabled
 	});
 
-	const extractInputVariables = (text: string): Record<string, any> => {
-		const regex = /{{\s*([^|}\s]+)\s*\|\s*([^}]+)\s*}}/g;
-		const regularRegex = /{{\s*([^|}\s]+)\s*}}/g;
-		const variables: Record<string, any> = {};
-		let match;
-		// 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);
-		}
-		// Then, extract regular variables (without pipe) - only if not already processed
-		while ((match = regularRegex.exec(text)) !== null) {
-			const varName = match[1].trim();
-			// Only add if not already processed as custom variable
-			if (!variables.hasOwnProperty(varName)) {
-				variables[varName] = { type: 'text' }; // Default type for regular variables
-			}
-		}
-		return variables;
-	};
-
-	const splitProperties = (str: string, delimiter: string): string[] => {
-		const result: string[] = [];
-		let current = '';
-		let depth = 0;
-		let inString = false;
-		let escapeNext = false;
-
-		for (let i = 0; i < str.length; i++) {
-			const char = str[i];
-
-			if (escapeNext) {
-				current += char;
-				escapeNext = false;
-				continue;
-			}
-
-			if (char === '\\') {
-				current += char;
-				escapeNext = true;
-				continue;
-			}
-
-			if (char === '"' && !escapeNext) {
-				inString = !inString;
-				current += char;
-				continue;
-			}
-
-			if (!inString) {
-				if (char === '{' || char === '[') {
-					depth++;
-				} else if (char === '}' || char === ']') {
-					depth--;
-				}
-
-				if (char === delimiter && depth === 0) {
-					result.push(current.trim());
-					current = '';
-					continue;
-				}
-			}
-
-			current += char;
-		}
-
-		if (current.trim()) {
-			result.push(current.trim());
-		}
-
-		return result;
-	};
-
-	const parseVariableDefinition = (definition: string): Record<string, any> => {
-		// Use splitProperties for the main colon delimiter to handle quoted strings
-		const parts = splitProperties(definition, ':');
-		const [firstPart, ...propertyParts] = parts;
-
-		// Parse type (explicit or implied)
-		const type = firstPart.startsWith('type=') ? firstPart.slice(5) : firstPart;
-
-		// Parse properties using reduce
-		const properties = propertyParts.reduce((props, part) => {
-			// Use splitProperties for the equals sign as well, in case there are nested quotes
-			const equalsParts = splitProperties(part, '=');
-			const [propertyName, ...valueParts] = equalsParts;
-			const propertyValue = valueParts.join('='); // Handle values with = signs
-
-			return propertyName && propertyValue
-				? {
-						...props,
-						[propertyName.trim()]: parseJsonValue(propertyValue.trim())
-					}
-				: props;
-		}, {});
-
-		return { type, ...properties };
-	};
-
-	const parseJsonValue = (value: string): any => {
-		// Remove surrounding quotes if present (for string values)
-		if (value.startsWith('"') && value.endsWith('"')) {
-			return value.slice(1, -1);
-		}
-
-		// 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 value;
-	};
-
 	const inputVariableHandler = async (text: string) => {
 		inputVariables = extractInputVariables(text);
 		if (Object.keys(inputVariables).length > 0) {

+ 118 - 0
src/lib/utils/index.ts

@@ -1389,3 +1389,121 @@ export const slugify = (str: string): string => {
 			.toLowerCase()
 	);
 };
+
+export const extractInputVariables = (text: string): Record<string, any> => {
+	const regex = /{{\s*([^|}\s]+)\s*\|\s*([^}]+)\s*}}/g;
+	const regularRegex = /{{\s*([^|}\s]+)\s*}}/g;
+	const variables: Record<string, any> = {};
+	let match;
+	// 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);
+	}
+	// Then, extract regular variables (without pipe) - only if not already processed
+	while ((match = regularRegex.exec(text)) !== null) {
+		const varName = match[1].trim();
+		// Only add if not already processed as custom variable
+		if (!variables.hasOwnProperty(varName)) {
+			variables[varName] = { type: 'text' }; // Default type for regular variables
+		}
+	}
+	return variables;
+};
+
+export const splitProperties = (str: string, delimiter: string): string[] => {
+	const result: string[] = [];
+	let current = '';
+	let depth = 0;
+	let inString = false;
+	let escapeNext = false;
+
+	for (let i = 0; i < str.length; i++) {
+		const char = str[i];
+
+		if (escapeNext) {
+			current += char;
+			escapeNext = false;
+			continue;
+		}
+
+		if (char === '\\') {
+			current += char;
+			escapeNext = true;
+			continue;
+		}
+
+		if (char === '"' && !escapeNext) {
+			inString = !inString;
+			current += char;
+			continue;
+		}
+
+		if (!inString) {
+			if (char === '{' || char === '[') {
+				depth++;
+			} else if (char === '}' || char === ']') {
+				depth--;
+			}
+
+			if (char === delimiter && depth === 0) {
+				result.push(current.trim());
+				current = '';
+				continue;
+			}
+		}
+
+		current += char;
+	}
+
+	if (current.trim()) {
+		result.push(current.trim());
+	}
+
+	return result;
+};
+
+export const parseVariableDefinition = (definition: string): Record<string, any> => {
+	// Use splitProperties for the main colon delimiter to handle quoted strings
+	const parts = splitProperties(definition, ':');
+	const [firstPart, ...propertyParts] = parts;
+
+	// Parse type (explicit or implied)
+	const type = firstPart.startsWith('type=') ? firstPart.slice(5) : firstPart;
+
+	// Parse properties using reduce
+	const properties = propertyParts.reduce((props, part) => {
+		// Use splitProperties for the equals sign as well, in case there are nested quotes
+		const equalsParts = splitProperties(part, '=');
+		const [propertyName, ...valueParts] = equalsParts;
+		const propertyValue = valueParts.join('='); // Handle values with = signs
+
+		return propertyName && propertyValue
+			? {
+					...props,
+					[propertyName.trim()]: parseJsonValue(propertyValue.trim())
+				}
+			: props;
+	}, {});
+
+	return { type, ...properties };
+};
+
+export const parseJsonValue = (value: string): any => {
+	// Remove surrounding quotes if present (for string values)
+	if (value.startsWith('"') && value.endsWith('"')) {
+		return value.slice(1, -1);
+	}
+
+	// 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 value;
+};