diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index a57fbc8cf0..04c30d038c 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2586,14 +2586,44 @@ const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed'); toast.error(message, { id: toastId }); } finally { - messageInput?.setText(''); - prompt = ''; document.getElementById('chat-input')?.focus(); } }; const handleStatusCommand = () => { messageInput?.showStatus(); + document.getElementById('chat-input')?.focus(); + }; + + const handleModelCommand = (modelId = '') => { + if (!modelId) { + const currentModels = (atSelectedModel?.id ? [atSelectedModel.id] : selectedModels).filter( + Boolean + ); + toast.message( + currentModels.length + ? `Current model: ${currentModels.join(', ')}` + : $i18n.t('Model not selected') + ); + messageInput?.setText(''); + prompt = ''; + document.getElementById('chat-input')?.focus(); + return; + } + + const model = $models.find((model) => model.id === modelId); + if (!model) { + toast.error(`Model not found: ${modelId}`); + messageInput?.setText(''); + prompt = ''; + document.getElementById('chat-input')?.focus(); + return; + } + + atSelectedModel = undefined; + selectedModels = [model.id]; + saveSessionSelectedModels(); + toast.success(`Model switched to: ${model.id}`); messageInput?.setText(''); prompt = ''; document.getElementById('chat-input')?.focus(); @@ -2640,12 +2670,15 @@ } catch (error) { toast.error(`${error}`, { id: toastId }); } finally { - messageInput?.setText(''); - prompt = ''; document.getElementById('chat-input')?.focus(); } }; + const clearCommandInput = () => { + messageInput?.setText(''); + prompt = ''; + }; + const submitHandler = async (userPrompt, { _raw = false } = {}) => { console.log('submitHandler', userPrompt, $chatId); @@ -2658,17 +2691,27 @@ } if (String(userPrompt).trim() === '/compact') { + clearCommandInput(); await handleManualCompact(); return; } if (String(userPrompt).trim() === '/status') { + clearCommandInput(); handleStatusCommand(); return; } if (String(userPrompt).trim() === '/fork') { + clearCommandInput(); await handleForkChat(); return; } + const modelCommandMatch = String(userPrompt) + .trim() + .match(/^\/model(?:\s+([\s\S]+))?$/); + if (modelCommandMatch) { + handleModelCommand(modelCommandMatch[1]?.trim() ?? ''); + return; + } if (pendingOAuthTools.length > 0) { toast.warning($i18n.t('Please connect all required integrations before sending a message')); diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 883bdbad08..a51ed32c3a 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -3,6 +3,8 @@ import { toast } from 'svelte-sonner'; import { marked } from 'marked'; + import { DOMParser } from 'prosemirror-model'; + import { Selection, TextSelection } from 'prosemirror-state'; import { v4 as uuidv4 } from 'uuid'; import dayjs from '$lib/dayjs'; import duration from 'dayjs/plugin/duration'; @@ -547,6 +549,63 @@ } }; + const replaceSlashRangeWithPrompt = async (editor, range, text: string) => { + text = await textVariableHandler(text); + + const { state, view } = editor; + let tr = state.tr; + + if ($settings?.insertPromptAsRichText ?? false) { + const htmlContent = DOMPurify.sanitize( + marked + .parse(text, { + breaks: true, + gfm: true + }) + .trim() + ); + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = htmlContent; + const fragment = DOMParser.fromSchema(state.schema).parse(tempDiv); + const nodesToInsert = []; + + fragment.content.forEach((node) => { + if (node.type.name === 'paragraph') { + nodesToInsert.push(...node.content.content); + } else { + nodesToInsert.push(node); + } + }); + + tr = tr.replaceWith(range.from, range.to, nodesToInsert); + const newPos = range.from + nodesToInsert.reduce((sum, node) => sum + node.nodeSize, 0); + tr = tr.setSelection(Selection.near(tr.doc.resolve(newPos))); + } else if (text.includes('\n')) { + const nodes = text + .split('\n') + .map((line, index) => + index === 0 + ? state.schema.text(line ? line : []) + : state.schema.nodes.paragraph.create({}, line ? state.schema.text(line) : undefined) + ); + tr = tr.replaceWith(range.from, range.to, nodes); + const newPos = nodes.reduce((pos, node) => pos + node.nodeSize, range.from); + tr = tr.setSelection(TextSelection.near(tr.doc.resolve(newPos))); + } else { + tr = tr.replaceWith(range.from, range.to, text !== '' ? state.schema.text(text) : []); + tr = tr.setSelection( + state.selection.constructor.near(tr.doc.resolve(range.from + text.length + 1)) + ); + } + + view.dispatch(tr); + + await tick(); + await inputVariableHandler(text); + await tick(); + document.getElementById('chat-input')?.focus(); + }; + let command = ''; export let showCommands = false; $: showCommands = @@ -587,6 +646,7 @@ let chatInputContainerElement; let chatInputElement; + let modelSelector; let filesInputElement; let commandsElement; @@ -1178,6 +1238,29 @@ }, { char: '/', + command: ({ editor, range, props }) => { + if (props?.type === 'prompt') { + void replaceSlashRangeWithPrompt(editor, range, props.content ?? ''); + return; + } + + if (['compact', 'fork', 'status', 'model'].includes(props?.id)) { + editor.chain().focus().deleteRange(range).run(); + return; + } + + editor + .chain() + .focus() + .insertContentAt(range, [ + { + type: 'mention', + attrs: props + }, + { type: 'text', text: ' ' } + ]) + .run(); + }, render: getSuggestionRenderer(CommandSuggestionList, { i18n, canCompact: () => !!history?.currentId && contextCompactionEnabled, @@ -1189,6 +1272,7 @@ onCompact: compactHandler, onStatus: statusHandler, onFork: forkHandler, + onModel: () => modelSelector?.open(), onSelect: (e) => { const { type, data } = e; @@ -2238,6 +2322,7 @@