This commit is contained in:
Timothy Jaeryang Baek committed 2026-08-05 07:12:28 -05:00
1 parent 9c7ce154e7
commit 29eeda9f9a
6 files changed
+205 -13

No files matched your search

+47 -4
View File
@@ -2586,14 +2586,44 @@
const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed'); const message = error?.detail ?? error?.message ?? $i18n.t('Context compaction failed');
toast.error(message, { id: toastId }); toast.error(message, { id: toastId });
} finally { } finally {
messageInput?.setText('');
prompt = '';
document.getElementById('chat-input')?.focus(); document.getElementById('chat-input')?.focus();
} }
}; };
const handleStatusCommand = () => { const handleStatusCommand = () => {
messageInput?.showStatus(); 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(''); messageInput?.setText('');
prompt = ''; prompt = '';
document.getElementById('chat-input')?.focus(); document.getElementById('chat-input')?.focus();
@@ -2640,12 +2670,15 @@
} catch (error) { } catch (error) {
toast.error(`${error}`, { id: toastId }); toast.error(`${error}`, { id: toastId });
} finally { } finally {
messageInput?.setText('');
prompt = '';
document.getElementById('chat-input')?.focus(); document.getElementById('chat-input')?.focus();
} }
}; };
const clearCommandInput = () => {
messageInput?.setText('');
prompt = '';
};
const submitHandler = async (userPrompt, { _raw = false } = {}) => { const submitHandler = async (userPrompt, { _raw = false } = {}) => {
console.log('submitHandler', userPrompt, $chatId); console.log('submitHandler', userPrompt, $chatId);
@@ -2658,17 +2691,27 @@
} }
if (String(userPrompt).trim() === '/compact') { if (String(userPrompt).trim() === '/compact') {
clearCommandInput();
await handleManualCompact(); await handleManualCompact();
return; return;
} }
if (String(userPrompt).trim() === '/status') { if (String(userPrompt).trim() === '/status') {
clearCommandInput();
handleStatusCommand(); handleStatusCommand();
return; return;
} }
if (String(userPrompt).trim() === '/fork') { if (String(userPrompt).trim() === '/fork') {
clearCommandInput();
await handleForkChat(); await handleForkChat();
return; return;
} }
const modelCommandMatch = String(userPrompt)
.trim()
.match(/^\/model(?:\s+([\s\S]+))?$/);
if (modelCommandMatch) {
handleModelCommand(modelCommandMatch[1]?.trim() ?? '');
return;
}
if (pendingOAuthTools.length > 0) { if (pendingOAuthTools.length > 0) {
toast.warning($i18n.t('Please connect all required integrations before sending a message')); toast.warning($i18n.t('Please connect all required integrations before sending a message'));
@@ -3,6 +3,8 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { marked } from 'marked'; import { marked } from 'marked';
import { DOMParser } from 'prosemirror-model';
import { Selection, TextSelection } from 'prosemirror-state';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import dayjs from '$lib/dayjs'; import dayjs from '$lib/dayjs';
import duration from 'dayjs/plugin/duration'; 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 = ''; let command = '';
export let showCommands = false; export let showCommands = false;
$: showCommands = $: showCommands =
@@ -587,6 +646,7 @@
let chatInputContainerElement; let chatInputContainerElement;
let chatInputElement; let chatInputElement;
let modelSelector;
let filesInputElement; let filesInputElement;
let commandsElement; let commandsElement;
@@ -1178,6 +1238,29 @@
}, },
{ {
char: '/', 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, { render: getSuggestionRenderer(CommandSuggestionList, {
i18n, i18n,
canCompact: () => !!history?.currentId && contextCompactionEnabled, canCompact: () => !!history?.currentId && contextCompactionEnabled,
@@ -1189,6 +1272,7 @@
onCompact: compactHandler, onCompact: compactHandler,
onStatus: statusHandler, onStatus: statusHandler,
onFork: forkHandler, onFork: forkHandler,
onModel: () => modelSelector?.open(),
onSelect: (e) => { onSelect: (e) => {
const { type, data } = e; const { type, data } = e;
@@ -2238,6 +2322,7 @@
<div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]"> <div class="self-end flex space-x-1 mr-1 shrink-0 gap-[0.5px]">
<div class="flex min-w-0 max-w-[10rem] items-center sm:max-w-[13rem]"> <div class="flex min-w-0 max-w-[10rem] items-center sm:max-w-[13rem]">
<ModelSelector <ModelSelector
bind:this={modelSelector}
bind:selectedModels bind:selectedModels
showSetDefault={!history?.currentId} showSetDefault={!history?.currentId}
placement="auto" placement="auto"
@@ -8,13 +8,19 @@
export let char = ''; export let char = '';
export let query = ''; export let query = '';
export let command: (payload: { id: string; label: string }) => void; export let command: (payload: {
id: string;
label: string;
content?: string;
type?: string;
}) => void;
export let onSelect: (e: any) => void = () => {}; export let onSelect: (e: any) => void = () => {};
export let onUpload: (e: any) => void = () => {}; export let onUpload: (e: any) => void = () => {};
export let onCompact: () => void = () => {}; export let onCompact: () => void = () => {};
export let onStatus: () => void = () => {}; export let onStatus: () => void = () => {};
export let onFork: () => void = () => {}; export let onFork: () => void = () => {};
export let onModel: () => void = () => {};
export let insertTextHandler: (text: string) => void = () => {}; export let insertTextHandler: (text: string) => void = () => {};
export let canCompact: boolean | (() => boolean) = false; export let canCompact: boolean | (() => boolean) = false;
export let compactDisabled: boolean | (() => boolean) = false; export let compactDisabled: boolean | (() => boolean) = false;
@@ -93,16 +99,24 @@
const { type, data } = e; const { type, data } = e;
if (type === 'prompt') { 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') { } else if (type === 'command' && data.id === 'compact') {
insertTextHandler(''); command({ id: data.id, label: data.id });
onCompact(); onCompact();
} else if (type === 'command' && data.id === 'status') { } else if (type === 'command' && data.id === 'status') {
insertTextHandler(''); command({ id: data.id, label: data.id });
onStatus(); onStatus();
} else if (type === 'command' && data.id === 'fork') { } else if (type === 'command' && data.id === 'fork') {
insertTextHandler(''); command({ id: data.id, label: data.id });
onFork(); onFork();
} else if (type === 'command' && data.id === 'model') {
command({ id: data.id, label: data.id });
onModel();
} else if (type === 'skill') { } else if (type === 'skill') {
command({ command({
id: `${data.id}|${data.name}`, id: `${data.id}|${data.name}`,
@@ -4,6 +4,7 @@
import { getSkillItems } from '$lib/apis/skills'; import { getSkillItems } from '$lib/apis/skills';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Cube from '$lib/components/icons/Cube.svelte'; import Cube from '$lib/components/icons/Cube.svelte';
import Sparkles from '$lib/components/icons/Sparkles.svelte';
const i18n = getContext('i18n'); const i18n = getContext('i18n');
@@ -38,7 +39,8 @@
: []), : []),
...(canStatus && 'status'.startsWith(query.toLowerCase()) ...(canStatus && 'status'.startsWith(query.toLowerCase())
? [{ type: 'command', data: { id: 'status' } }] ? [{ type: 'command', data: { id: 'status' } }]
: []) : []),
...('model'.startsWith(query.toLowerCase()) ? [{ type: 'command', data: { id: 'model' } }] : [])
]; ];
$: filteredPrompts = prompts $: filteredPrompts = prompts
@@ -266,6 +268,32 @@
</span> </span>
</button> </button>
</Tooltip> </Tooltip>
{:else if item.data.id === 'model'}
<Tooltip content="Show or switch the current model." placement="top">
<button
type="button"
aria-label="Model: show or switch the current model."
class="slash-command-row flex items-center gap-2 w-full h-6 px-2 rounded-xl text-xs text-left transition-colors duration-75
{commandIdx === selectedIdx ? 'app-interactive-active' : ''}"
on:mousedown={(e) => e.preventDefault()}
on:click={() => {
onSelect(item);
}}
on:mouseenter={() => {
selectedIdx = commandIdx;
}}
on:focus={() => {}}
data-selected={commandIdx === selectedIdx}
>
<span class="app-icon-muted flex items-center justify-center w-4 shrink-0">
<Sparkles className="size-3.5" />
</span>
<span class="flex-1 min-w-0 flex items-baseline gap-1.5 overflow-hidden">
<span class="truncate">Model</span>
<span class="app-muted text-[0.625rem] truncate shrink-0">/model</span>
</span>
</button>
</Tooltip>
{/if} {/if}
{/each} {/each}
{/if} {/if}
@@ -19,6 +19,7 @@
import { import {
user, user,
MODEL_DOWNLOAD_POOL, MODEL_DOWNLOAD_POOL,
mobile,
models, models,
temporaryChatEnabled, temporaryChatEnabled,
settings, settings,
@@ -173,6 +174,17 @@
schedulePositionUpdate(); schedulePositionUpdate();
}; };
const focusSearchInput = () => {
if (!$mobile) {
document.getElementById('model-search-input')?.focus();
}
};
const focusChatInput = () => {
if (!$mobile) {
document.getElementById('chat-input')?.focus();
}
};
const toggleOpen = async () => { const toggleOpen = async () => {
show = !show; show = !show;
if (show) { if (show) {
@@ -182,12 +194,20 @@
updatePosition(); updatePosition();
await tick(); await tick();
updatePosition(); updatePosition();
window.setTimeout(() => document.getElementById('model-search-input')?.focus(), 0); for (const delay of [0, 50, 150]) {
window.setTimeout(focusSearchInput, delay);
}
} else { } else {
document.getElementById(`model-selector-${id}-button`)?.blur(); document.getElementById(`model-selector-${id}-button`)?.blur();
} }
}; };
export const open = async () => {
if (!show) {
await toggleOpen();
}
};
const handlePointerDown = (e: PointerEvent) => { const handlePointerDown = (e: PointerEvent) => {
if (!show) return; if (!show) return;
const target = e.target as Node; const target = e.target as Node;
@@ -416,11 +436,13 @@
values = [item.value]; values = [item.value];
value = item.value; value = item.value;
show = false; show = false;
window.setTimeout(focusChatInput, 0);
return; return;
} }
value = item.value; value = item.value;
show = false; show = false;
window.setTimeout(focusChatInput, 0);
}; };
const setDefaultHandler = async () => { const setDefaultHandler = async () => {
@@ -25,7 +25,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) {
char: props?.text?.charAt(0), char: props?.text?.charAt(0),
query: props?.query, query: props?.query,
command: (item) => { command: (item) => {
props.command({ id: item.id, label: item.label }); props.command(item);
}, },
...ComponentProps ...ComponentProps
}, },
@@ -88,7 +88,7 @@ export function getSuggestionRenderer(Component: any, ComponentProps = {}) {
component.$set({ component.$set({
query: props.query, query: props.query,
command: (item) => { command: (item) => {
props.command({ id: item.id, label: item.label }); props.command(item);
} }
}); });