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 @@
void; + export let command: (payload: { + id: string; + label: string; + content?: string; + type?: string; + }) => void; export let onSelect: (e: any) => void = () => {}; export let onUpload: (e: any) => void = () => {}; export let onCompact: () => void = () => {}; export let onStatus: () => void = () => {}; export let onFork: () => void = () => {}; + export let onModel: () => void = () => {}; export let insertTextHandler: (text: string) => void = () => {}; export let canCompact: boolean | (() => boolean) = false; export let compactDisabled: boolean | (() => boolean) = false; @@ -93,16 +99,24 @@ const { type, data } = e; if (type === 'prompt') { - insertTextHandler(data.content); + command({ + id: data.command, + label: data.command, + content: data.content, + type: 'prompt' + }); } else if (type === 'command' && data.id === 'compact') { - insertTextHandler(''); + command({ id: data.id, label: data.id }); onCompact(); } else if (type === 'command' && data.id === 'status') { - insertTextHandler(''); + command({ id: data.id, label: data.id }); onStatus(); } else if (type === 'command' && data.id === 'fork') { - insertTextHandler(''); + command({ id: data.id, label: data.id }); onFork(); + } else if (type === 'command' && data.id === 'model') { + command({ id: data.id, label: data.id }); + onModel(); } else if (type === 'skill') { command({ id: `${data.id}|${data.name}`, diff --git a/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte b/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte index 75ec04ee1a..5f2fb0b27d 100644 --- a/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte +++ b/src/lib/components/chat/MessageInput/Commands/SlashCommands.svelte @@ -4,6 +4,7 @@ import { getSkillItems } from '$lib/apis/skills'; import Tooltip from '$lib/components/common/Tooltip.svelte'; import Cube from '$lib/components/icons/Cube.svelte'; + import Sparkles from '$lib/components/icons/Sparkles.svelte'; const i18n = getContext('i18n'); @@ -38,7 +39,8 @@ : []), ...(canStatus && 'status'.startsWith(query.toLowerCase()) ? [{ type: 'command', data: { id: 'status' } }] - : []) + : []), + ...('model'.startsWith(query.toLowerCase()) ? [{ type: 'command', data: { id: 'model' } }] : []) ]; $: filteredPrompts = prompts @@ -266,6 +268,32 @@ + {:else if item.data.id === 'model'} + + + {/if} {/each} {/if} diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 28654d6897..a2a498928c 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -19,6 +19,7 @@ import { user, MODEL_DOWNLOAD_POOL, + mobile, models, temporaryChatEnabled, settings, @@ -173,6 +174,17 @@ schedulePositionUpdate(); }; + const focusSearchInput = () => { + if (!$mobile) { + document.getElementById('model-search-input')?.focus(); + } + }; + const focusChatInput = () => { + if (!$mobile) { + document.getElementById('chat-input')?.focus(); + } + }; + const toggleOpen = async () => { show = !show; if (show) { @@ -182,12 +194,20 @@ updatePosition(); await tick(); updatePosition(); - window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); + for (const delay of [0, 50, 150]) { + window.setTimeout(focusSearchInput, delay); + } } else { document.getElementById(`model-selector-${id}-button`)?.blur(); } }; + export const open = async () => { + if (!show) { + await toggleOpen(); + } + }; + const handlePointerDown = (e: PointerEvent) => { if (!show) return; const target = e.target as Node; @@ -416,11 +436,13 @@ values = [item.value]; value = item.value; show = false; + window.setTimeout(focusChatInput, 0); return; } value = item.value; show = false; + window.setTimeout(focusChatInput, 0); }; const setDefaultHandler = async () => { diff --git a/src/lib/components/common/RichTextInput/suggestions.ts b/src/lib/components/common/RichTextInput/suggestions.ts index 4fc8132b72..9dbfc5048b 100644 --- a/src/lib/components/common/RichTextInput/suggestions.ts +++ b/src/lib/components/common/RichTextInput/suggestions.ts @@ -25,7 +25,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { char: props?.text?.charAt(0), query: props?.query, command: (item) => { - props.command({ id: item.id, label: item.label }); + props.command(item); }, ...ComponentProps }, @@ -88,7 +88,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) { component.$set({ query: props.query, command: (item) => { - props.command({ id: item.id, label: item.label }); + props.command(item); } });