enh: support commands in note chat

This commit is contained in:
Timothy Jaeryang Baek 2025-07-09 01:49:43 +04:00
parent e9821c0881
commit 9632f40335
3 changed files with 375 additions and 134 deletions

View file

@ -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,10 +708,59 @@
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
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' });
}
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 ||
!(
@ -541,6 +780,7 @@
submitHandler();
}
}
}
if (e.key === 'Escape') {
console.info('Escape');

View file

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

View file

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