mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
Merge pull request #18473 from silentoplayz/hotkey-hints-sidebar
feat: add toggleable hotkey hints to sidebar buttons and refac ShortcutsModal
This commit is contained in:
commit
710f6eec12
8 changed files with 492 additions and 471 deletions
|
|
@ -1050,6 +1050,12 @@
|
|||
dispatch('submit', prompt);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
id="generate-message-pair-button"
|
||||
class="hidden"
|
||||
on:click={() => createMessagePair(prompt)}
|
||||
/>
|
||||
|
||||
<div
|
||||
id="message-input-container"
|
||||
class="flex-1 flex flex-col relative w-full shadow-lg rounded-3xl border {$temporaryChatEnabled
|
||||
|
|
@ -1254,24 +1260,6 @@
|
|||
stopResponse();
|
||||
}
|
||||
|
||||
// Command/Ctrl + Shift + Enter to submit a message pair
|
||||
if (isCtrlPressed && e.key === 'Enter' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
createMessagePair(prompt);
|
||||
}
|
||||
|
||||
// Check if Ctrl + R is pressed
|
||||
if (prompt === '' && isCtrlPressed && e.key.toLowerCase() === 'r') {
|
||||
e.preventDefault();
|
||||
console.log('regenerate');
|
||||
|
||||
const regenerateButton = [
|
||||
...document.getElementsByClassName('regenerate-response-button')
|
||||
]?.at(-1);
|
||||
|
||||
regenerateButton?.click();
|
||||
}
|
||||
|
||||
if (prompt === '' && e.key == 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
|
||||
|
|
|
|||
71
src/lib/components/chat/ShortcutItem.svelte
Normal file
71
src/lib/components/chat/ShortcutItem.svelte
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
import type { Shortcut } from '$lib/shortcuts';
|
||||
|
||||
export let shortcut: Shortcut;
|
||||
export let isMac: boolean;
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
function formatKey(key: string): string {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
switch (lowerKey) {
|
||||
case 'mod':
|
||||
return isMac ? '⌘' : 'Ctrl';
|
||||
case 'shift':
|
||||
return isMac ? '⇧' : 'Shift';
|
||||
case 'alt':
|
||||
return isMac ? '⌥' : 'Alt';
|
||||
case 'backspace':
|
||||
case 'delete':
|
||||
return isMac ? '⌫' : 'Delete';
|
||||
case 'escape':
|
||||
return 'Esc';
|
||||
case 'enter':
|
||||
return isMac ? '↩' : 'Enter';
|
||||
case 'tab':
|
||||
return isMac ? '⇥' : 'Tab';
|
||||
case 'arrowup':
|
||||
return '↑';
|
||||
case 'arrowdown':
|
||||
return '↓';
|
||||
case 'quote':
|
||||
return "'";
|
||||
case 'period':
|
||||
return '.';
|
||||
case 'slash':
|
||||
return '/';
|
||||
case 'semicolon':
|
||||
return ';';
|
||||
default:
|
||||
if (lowerKey.startsWith('key')) return key.slice(-1);
|
||||
if (lowerKey.startsWith('digit')) return key.slice(-1);
|
||||
return key.toUpperCase();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class="text-sm whitespace-pre-line">
|
||||
{#if shortcut.tooltip}
|
||||
<Tooltip content={$i18n.t(shortcut.tooltip)}>
|
||||
<span class="whitespace-nowrap">
|
||||
{$i18n.t(shortcut.name)}<span class="text-xs"> *</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
{:else}
|
||||
{$i18n.t(shortcut.name)}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex-shrink-0 flex flex-wrap justify-end items-center self-center space-x-1 text-xs">
|
||||
{#each shortcut.keys.filter(key => !(key.toLowerCase() === 'delete' && shortcut.keys.includes('Backspace'))) as key}
|
||||
<div
|
||||
class="h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
{formatKey(key)}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1,392 +1,118 @@
|
|||
<script lang="ts">
|
||||
import { getContext } from 'svelte';
|
||||
import { getContext, onMount } from 'svelte';
|
||||
import Modal from '../common/Modal.svelte';
|
||||
|
||||
import Tooltip from '../common/Tooltip.svelte';
|
||||
const i18n = getContext('i18n');
|
||||
import { shortcuts } from '$lib/shortcuts';
|
||||
import { settings } from '$lib/stores';
|
||||
import ShortcutItem from './ShortcutItem.svelte';
|
||||
import XMark from '$lib/components/icons/XMark.svelte';
|
||||
|
||||
type CategorizedShortcuts = {
|
||||
[category: string]: {
|
||||
left: Shortcut[];
|
||||
right: Shortcut[];
|
||||
};
|
||||
};
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
export let show = false;
|
||||
|
||||
let categorizedShortcuts: CategorizedShortcuts = {};
|
||||
let isMac = false;
|
||||
|
||||
onMount(() => {
|
||||
isMac = /Mac/i.test(navigator.userAgent);
|
||||
});
|
||||
|
||||
$: {
|
||||
const allShortcuts = Object.values(shortcuts).filter((shortcut) => {
|
||||
if (!shortcut.setting) {
|
||||
return true;
|
||||
}
|
||||
return $settings[shortcut.setting.id] === shortcut.setting.value;
|
||||
});
|
||||
|
||||
const result = allShortcuts.reduce((acc, shortcut) => {
|
||||
const category = shortcut.category;
|
||||
if (!acc[category]) {
|
||||
acc[category] = [];
|
||||
}
|
||||
acc[category].push(shortcut);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const newCategorizedShortcuts = {};
|
||||
for (const category in result) {
|
||||
const half = Math.ceil(result[category].length / 2);
|
||||
newCategorizedShortcuts[category] = {
|
||||
left: result[category].slice(0, half),
|
||||
right: result[category].slice(half)
|
||||
};
|
||||
}
|
||||
categorizedShortcuts = newCategorizedShortcuts;
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal bind:show>
|
||||
<div class="text-gray-700 dark:text-gray-100">
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5 pt-4">
|
||||
<div class=" text-lg font-medium self-center">{$i18n.t('Keyboard shortcuts')}</div>
|
||||
<button
|
||||
class="self-center"
|
||||
on:click={() => {
|
||||
show = false;
|
||||
}}
|
||||
>
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4">
|
||||
<div class="text-lg font-medium self-center">{$i18n.t('Keyboard Shortcuts')}</div>
|
||||
<button class="self-center" on:click={() => (show = false)}>
|
||||
<XMark className={'size-5'} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<div class="flex flex-col space-y-3 w-full self-start">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Open new chat')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
O
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Focus chat input')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Esc
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Only active when the chat input is in focus and an LLM is generating a response.'
|
||||
)}
|
||||
>
|
||||
{$i18n.t('Stop Generating')}<span class="text-xs"> *</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Esc
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Copy last code block')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
;
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Copy last response')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
C
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">
|
||||
<Tooltip
|
||||
content={$i18n.t(
|
||||
'Only active when "Paste Large Text as File" setting is toggled on.'
|
||||
)}
|
||||
>
|
||||
{$i18n.t('Prevent file creation')}<span class="text-s"> *</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
V
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{#each Object.entries(categorizedShortcuts) as [category, columns], i}
|
||||
{#if i > 0}
|
||||
<div class="px-5">
|
||||
<div class="w-full border-t dark:border-gray-700 border-gray-200" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex flex-col space-y-3 w-full self-start">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Generate prompt pair')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Enter
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between dark:text-gray-300 px-5 pt-4">
|
||||
<div class="text-lg font-medium self-center">{$i18n.t(category)}</div>
|
||||
</div>
|
||||
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
|
||||
<div class="flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<div class="flex flex-col space-y-3 w-full self-start">
|
||||
{#each columns.left as shortcut}
|
||||
<ShortcutItem {shortcut} {isMac} />
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Toggle search')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
K
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Toggle settings')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Toggle sidebar')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
S
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Delete chat')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Shift
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
⌫/Delete
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">{$i18n.t('Show shortcuts')}</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
Ctrl/⌘
|
||||
</div>
|
||||
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
/
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col space-y-3 w-full self-start">
|
||||
{#each columns.right as shortcut}
|
||||
<ShortcutItem {shortcut} {isMac} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<div class="px-5 pb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t(
|
||||
{@html $i18n.t(
|
||||
'Shortcuts with an asterisk (*) are situational and only active under specific conditions.'
|
||||
)}
|
||||
</div>
|
||||
<div class=" flex justify-between dark:text-gray-300 px-5">
|
||||
<div class=" text-lg font-medium self-center">{$i18n.t('Input commands')}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row w-full p-5 md:space-x-4 dark:text-gray-200">
|
||||
<div class=" flex flex-col w-full sm:flex-row sm:justify-center sm:space-x-6">
|
||||
<div class="flex flex-col space-y-3 w-full self-start">
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">
|
||||
{$i18n.t('Attach file from knowledge')}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
#
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">
|
||||
{$i18n.t('Add custom prompt')}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
/
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">
|
||||
{$i18n.t('Talk to model')}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full flex justify-between items-center">
|
||||
<div class=" text-sm">
|
||||
{$i18n.t('Accept autocomplete generation / Jump to prompt variable')}
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 text-xs">
|
||||
<div
|
||||
class=" h-fit py-1 px-2 flex items-center justify-center rounded-sm border border-black/10 capitalize text-gray-600 dark:border-white/10 dark:text-gray-300"
|
||||
>
|
||||
TAB
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style>
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
/* display: none; <- Crashes Chrome on hover */
|
||||
-webkit-appearance: none;
|
||||
margin: 0; /* <-- Apparently some margin are still there even though it's hidden */
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.tabs::-webkit-scrollbar {
|
||||
display: none; /* for Chrome, Safari and Opera */
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield; /* Firefox */
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
37
src/lib/components/common/HotkeyHint.svelte
Normal file
37
src/lib/components/common/HotkeyHint.svelte
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { shortcuts } from '$lib/shortcuts';
|
||||
import { settings } from '$lib/stores';
|
||||
|
||||
export let name: string;
|
||||
export let className = '';
|
||||
|
||||
let isMac = false;
|
||||
let mounted = false;
|
||||
let keys: string[] = [];
|
||||
let isVisible = true;
|
||||
|
||||
onMount(() => {
|
||||
isMac = /Mac/i.test(navigator.userAgent);
|
||||
keys = shortcuts[name]?.keys ?? [];
|
||||
mounted = true;
|
||||
});
|
||||
|
||||
function formatKey(key: string): string {
|
||||
const lowerKey = key.toLowerCase();
|
||||
|
||||
if (lowerKey === 'mod') return isMac ? '⌘' : 'Ctrl';
|
||||
if (lowerKey === 'shift') return isMac ? '⇧' : 'Shift';
|
||||
if (lowerKey.startsWith('key')) return key.slice(-1);
|
||||
|
||||
return key;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if mounted && isVisible}
|
||||
<div
|
||||
class="hidden md:flex items-center self-center text-xs text-gray-400 dark:text-gray-600 {className}"
|
||||
>
|
||||
<span>{keys.map(formatKey).join(isMac ? '' : '+')}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -62,6 +62,7 @@
|
|||
import PinnedModelList from './Sidebar/PinnedModelList.svelte';
|
||||
import Note from '../icons/Note.svelte';
|
||||
import { slide } from 'svelte/transition';
|
||||
import HotkeyHint from '../common/HotkeyHint.svelte';
|
||||
|
||||
const BREAKPOINT = 768;
|
||||
|
||||
|
|
@ -787,7 +788,7 @@
|
|||
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<a
|
||||
id="sidebar-new-chat-button"
|
||||
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
class="group grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
href="/"
|
||||
draggable="false"
|
||||
on:click={newChatHandler}
|
||||
|
|
@ -797,16 +798,18 @@
|
|||
<PencilSquare className=" size-4.5" strokeWidth="2" />
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class="flex flex-1 self-center translate-y-[0.5px]">
|
||||
<div class=" self-center text-sm font-primary">{$i18n.t('New Chat')}</div>
|
||||
</div>
|
||||
|
||||
<HotkeyHint name="newChat" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="px-[7px] flex justify-center text-gray-800 dark:text-gray-200">
|
||||
<button
|
||||
id="sidebar-search-button"
|
||||
class="grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
class="group grow flex items-center space-x-3 rounded-2xl px-2.5 py-2 hover:bg-gray-100 dark:hover:bg-gray-900 transition outline-none"
|
||||
on:click={() => {
|
||||
showSearch.set(true);
|
||||
}}
|
||||
|
|
@ -817,9 +820,10 @@
|
|||
<Search strokeWidth="2" className="size-4.5" />
|
||||
</div>
|
||||
|
||||
<div class="flex self-center translate-y-[0.5px]">
|
||||
<div class="flex flex-1 self-center translate-y-[0.5px]">
|
||||
<div class=" self-center text-sm font-primary">{$i18n.t('Search')}</div>
|
||||
</div>
|
||||
<HotkeyHint name="search" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1446,6 +1446,7 @@
|
|||
"Show image preview": "",
|
||||
"Show Model": "",
|
||||
"Show shortcuts": "",
|
||||
"Show Sidebar Hotkey Hints": "",
|
||||
"Show your support!": "",
|
||||
"Showcased creativity": "",
|
||||
"Sign in": "",
|
||||
|
|
|
|||
178
src/lib/shortcuts.ts
Normal file
178
src/lib/shortcuts.ts
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
type ShortcutRegistry = {
|
||||
[key in Shortcut]?: {
|
||||
name: string;
|
||||
keys: string[];
|
||||
category: string;
|
||||
tooltip?: string;
|
||||
setting?: {
|
||||
id: string;
|
||||
value: any;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export enum Shortcut {
|
||||
//Chat
|
||||
NEW_CHAT = 'newChat',
|
||||
NEW_TEMPORARY_CHAT = 'newTemporaryChat',
|
||||
DELETE_CHAT = 'deleteChat',
|
||||
|
||||
//Global
|
||||
SEARCH = 'search',
|
||||
OPEN_SETTINGS = 'openSettings',
|
||||
SHOW_SHORTCUTS = 'showShortcuts',
|
||||
TOGGLE_SIDEBAR = 'toggleSidebar',
|
||||
CLOSE_MODAL = 'closeModal',
|
||||
|
||||
//Input
|
||||
FOCUS_INPUT = 'focusInput',
|
||||
ACCEPT_AUTOCOMPLETE = 'acceptAutocomplete',
|
||||
PREVENT_FILE_CREATION = 'preventFileCreation',
|
||||
NAVIGATE_PROMPT_HISTORY_UP = 'navigatePromptHistoryUp',
|
||||
SEND_MESSAGE_NORMAL = 'sendMessageNormal',
|
||||
SEND_MESSAGE_MOD = 'sendMessageMod',
|
||||
ATTACH_FILE = 'attachFile',
|
||||
ADD_PROMPT = 'addPrompt',
|
||||
TALK_TO_MODEL = 'talkToModel',
|
||||
|
||||
//Message
|
||||
GENERATE_MESSAGE_PAIR = 'generateMessagePair',
|
||||
REGENERATE_RESPONSE = 'regenerateResponse',
|
||||
COPY_LAST_CODE_BLOCK = 'copyLastCodeBlock',
|
||||
COPY_LAST_RESPONSE = 'copyLastResponse',
|
||||
STOP_GENERATING = 'stopGenerating'
|
||||
}
|
||||
|
||||
export const shortcuts: ShortcutRegistry = {
|
||||
//Chat
|
||||
[Shortcut.NEW_CHAT]: {
|
||||
name: 'New Chat',
|
||||
keys: ['mod', 'shift', 'KeyO'],
|
||||
category: 'Chat'
|
||||
},
|
||||
[Shortcut.NEW_TEMPORARY_CHAT]: {
|
||||
name: 'New Temporary Chat',
|
||||
keys: ['mod', 'shift', 'Quote'],
|
||||
category: 'Chat'
|
||||
},
|
||||
[Shortcut.DELETE_CHAT]: {
|
||||
name: 'Delete Chat',
|
||||
keys: ['mod', 'shift', 'Backspace', 'Delete'],
|
||||
category: 'Chat'
|
||||
},
|
||||
|
||||
//Global
|
||||
[Shortcut.SEARCH]: {
|
||||
name: 'Search',
|
||||
keys: ['mod', 'KeyK'],
|
||||
category: 'Global'
|
||||
},
|
||||
[Shortcut.OPEN_SETTINGS]: {
|
||||
name: 'Open Settings',
|
||||
keys: ['mod', 'Period'],
|
||||
category: 'Global'
|
||||
},
|
||||
[Shortcut.SHOW_SHORTCUTS]: {
|
||||
name: 'Show Shortcuts',
|
||||
keys: ['mod', 'Slash'],
|
||||
category: 'Global'
|
||||
},
|
||||
[Shortcut.TOGGLE_SIDEBAR]: {
|
||||
name: 'Toggle Sidebar',
|
||||
keys: ['mod', 'shift', 'KeyS'],
|
||||
category: 'Global'
|
||||
},
|
||||
[Shortcut.CLOSE_MODAL]: {
|
||||
name: 'Close Modal',
|
||||
keys: ['Escape'],
|
||||
category: 'Global'
|
||||
},
|
||||
|
||||
//Input
|
||||
[Shortcut.FOCUS_INPUT]: {
|
||||
name: 'Focus Chat Input',
|
||||
keys: ['shift', 'Escape'],
|
||||
category: 'Input'
|
||||
},
|
||||
[Shortcut.ACCEPT_AUTOCOMPLETE]: {
|
||||
name: 'Accept Autocomplete Generation\nJump to Prompt Variable',
|
||||
keys: ['Tab'],
|
||||
category: 'Input'
|
||||
},
|
||||
[Shortcut.PREVENT_FILE_CREATION]: {
|
||||
name: 'Prevent File Creation',
|
||||
keys: ['mod', 'shift', 'KeyV'],
|
||||
category: 'Input',
|
||||
tooltip: 'Only active when "Paste Large Text as File" setting is toggled on.'
|
||||
},
|
||||
[Shortcut.SEND_MESSAGE_NORMAL]: {
|
||||
name: 'Send Message',
|
||||
keys: ['Enter'],
|
||||
category: 'Input',
|
||||
tooltip: 'The behavior of this shortcut is determined by the "Enter Key Behavior" setting.',
|
||||
setting: {
|
||||
id: 'ctrlEnterToSend',
|
||||
value: false
|
||||
}
|
||||
},
|
||||
[Shortcut.SEND_MESSAGE_MOD]: {
|
||||
name: 'Send Message',
|
||||
keys: ['mod', 'Enter'],
|
||||
category: 'Input',
|
||||
tooltip: 'The behavior of this shortcut is determined by the "Enter Key Behavior" setting.',
|
||||
setting: {
|
||||
id: 'ctrlEnterToSend',
|
||||
value: true
|
||||
}
|
||||
},
|
||||
[Shortcut.ATTACH_FILE]: {
|
||||
name: 'Attach File From Knowledge',
|
||||
keys: ['#'],
|
||||
category: 'Input'
|
||||
},
|
||||
[Shortcut.ADD_PROMPT]: {
|
||||
name: 'Add Custom Prompt',
|
||||
keys: ['/'],
|
||||
category: 'Input'
|
||||
},
|
||||
[Shortcut.TALK_TO_MODEL]: {
|
||||
name: 'Talk to Model',
|
||||
keys: ['@'],
|
||||
category: 'Input'
|
||||
},
|
||||
|
||||
//Message
|
||||
[Shortcut.GENERATE_MESSAGE_PAIR]: {
|
||||
name: 'Generate Message Pair',
|
||||
keys: ['mod', 'shift', 'Enter'],
|
||||
category: 'Message',
|
||||
tooltip: 'Only active when the chat input is in focus.'
|
||||
},
|
||||
[Shortcut.REGENERATE_RESPONSE]: {
|
||||
name: 'Regenerate Response',
|
||||
keys: ['mod', 'KeyR'],
|
||||
category: 'Message'
|
||||
},
|
||||
[Shortcut.STOP_GENERATING]: {
|
||||
name: 'Stop Generating',
|
||||
keys: ['Escape'],
|
||||
category: 'Message',
|
||||
tooltip: 'Only active when the chat input is in focus and an LLM is generating a response.'
|
||||
},
|
||||
[Shortcut.NAVIGATE_PROMPT_HISTORY_UP]: {
|
||||
name: 'Edit Last Message',
|
||||
keys: ['ArrowUp'],
|
||||
category: 'Message',
|
||||
tooltip: 'Only can be triggered when the chat input is in focus.'
|
||||
},
|
||||
[Shortcut.COPY_LAST_RESPONSE]: {
|
||||
name: 'Copy Last Response',
|
||||
keys: ['mod', 'shift', 'KeyC'],
|
||||
category: 'Message'
|
||||
},
|
||||
[Shortcut.COPY_LAST_CODE_BLOCK]: {
|
||||
name: 'Copy Last Code Block',
|
||||
keys: ['mod', 'shift', 'Semicolon'],
|
||||
category: 'Message'
|
||||
}
|
||||
};
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
import AccountPending from '$lib/components/layout/Overlay/AccountPending.svelte';
|
||||
import UpdateInfoToast from '$lib/components/layout/UpdateInfoToast.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
import { Shortcut, shortcuts } from '$lib/shortcuts';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
|
|
@ -163,105 +164,120 @@
|
|||
})
|
||||
]);
|
||||
|
||||
const setupKeyboardShortcuts = () => {
|
||||
document.addEventListener('keydown', async function (event) {
|
||||
const isCtrlPressed = event.ctrlKey || event.metaKey; // metaKey is for Cmd key on Mac
|
||||
// Check if the Shift key is pressed
|
||||
const isShiftPressed = event.shiftKey;
|
||||
// Helper function to check if the pressed keys match the shortcut definition
|
||||
const checkShortcut = (event: KeyboardEvent, keys: string[]): boolean => {
|
||||
const lowerCaseKeys = keys.map((key) => key.toLowerCase());
|
||||
|
||||
// Check if Ctrl + K is pressed
|
||||
if (isCtrlPressed && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
console.log('search');
|
||||
showSearch.set(!$showSearch);
|
||||
const isModPressed = lowerCaseKeys.includes('mod') ? event.ctrlKey || event.metaKey : true;
|
||||
const isShiftPressed = lowerCaseKeys.includes('shift') ? event.shiftKey : true;
|
||||
const isAltPressed = lowerCaseKeys.includes('alt') ? event.altKey : true;
|
||||
const isCtrlPressed = lowerCaseKeys.includes('ctrl') ? event.ctrlKey : true;
|
||||
|
||||
const mainKeys = lowerCaseKeys.filter((key) => !['mod', 'shift', 'alt', 'ctrl'].includes(key));
|
||||
|
||||
if (mainKeys.length > 0 && !mainKeys.includes(event.code.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let modConflict = false;
|
||||
if (keys.includes('mod')) {
|
||||
if (!lowerCaseKeys.includes('shift') && event.shiftKey) modConflict = true;
|
||||
if (!lowerCaseKeys.includes('alt') && event.altKey) modConflict = true;
|
||||
}
|
||||
|
||||
return (
|
||||
!modConflict &&
|
||||
isModPressed &&
|
||||
isShiftPressed &&
|
||||
isAltPressed &&
|
||||
isCtrlPressed &&
|
||||
(event.ctrlKey || event.metaKey) === (lowerCaseKeys.includes('mod') || lowerCaseKeys.includes('ctrl')) &&
|
||||
event.shiftKey === lowerCaseKeys.includes('shift') &&
|
||||
event.altKey === lowerCaseKeys.includes('alt')
|
||||
);
|
||||
};
|
||||
|
||||
const setupKeyboardShortcuts = () => {
|
||||
document.addEventListener('keydown', async (event) => {
|
||||
for (const shortcutName in shortcuts) {
|
||||
const shortcut = shortcuts[shortcutName];
|
||||
if (checkShortcut(event, shortcut.keys)) {
|
||||
// Here you can map the shortcut name to an action
|
||||
// This is a simple example, you might want a more robust solution
|
||||
switch (shortcutName) {
|
||||
case Shortcut.SEARCH:
|
||||
event.preventDefault();
|
||||
showSearch.set(!$showSearch);
|
||||
break;
|
||||
case Shortcut.NEW_CHAT:
|
||||
event.preventDefault();
|
||||
document.getElementById('sidebar-new-chat-button')?.click();
|
||||
break;
|
||||
case Shortcut.FOCUS_INPUT:
|
||||
event.preventDefault();
|
||||
document.getElementById('chat-input')?.focus();
|
||||
break;
|
||||
case Shortcut.COPY_LAST_CODE_BLOCK:
|
||||
event.preventDefault();
|
||||
[...document.getElementsByClassName('copy-code-button')]?.at(-1)?.click();
|
||||
break;
|
||||
case Shortcut.COPY_LAST_RESPONSE:
|
||||
event.preventDefault();
|
||||
[...document.getElementsByClassName('copy-response-button')]?.at(-1)?.click();
|
||||
break;
|
||||
case Shortcut.TOGGLE_SIDEBAR:
|
||||
event.preventDefault();
|
||||
showSidebar.set(!$showSidebar);
|
||||
break;
|
||||
case Shortcut.DELETE_CHAT:
|
||||
event.preventDefault();
|
||||
document.getElementById('delete-chat-button')?.click();
|
||||
break;
|
||||
case Shortcut.OPEN_SETTINGS:
|
||||
event.preventDefault();
|
||||
showSettings.set(!$showSettings);
|
||||
break;
|
||||
case Shortcut.SHOW_SHORTCUTS:
|
||||
event.preventDefault();
|
||||
showShortcuts.set(!$showShortcuts);
|
||||
break;
|
||||
case Shortcut.CLOSE_MODAL:
|
||||
event.preventDefault();
|
||||
showSettings.set(false);
|
||||
showShortcuts.set(false);
|
||||
break;
|
||||
case Shortcut.NEW_TEMPORARY_CHAT:
|
||||
event.preventDefault();
|
||||
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
|
||||
temporaryChatEnabled.set(true);
|
||||
} else {
|
||||
temporaryChatEnabled.set(!$temporaryChatEnabled);
|
||||
}
|
||||
await goto('/');
|
||||
setTimeout(() => {
|
||||
document.getElementById('new-chat-button')?.click();
|
||||
}, 0);
|
||||
break;
|
||||
case Shortcut.GENERATE_MESSAGE_PAIR:
|
||||
event.preventDefault();
|
||||
document.getElementById('generate-message-pair-button')?.click();
|
||||
break;
|
||||
|
||||
case Shortcut.REGENERATE_RESPONSE:
|
||||
event.preventDefault();
|
||||
[...document.getElementsByClassName('regenerate-response-button')]?.at(-1)?.click();
|
||||
break;
|
||||
case Shortcut.STOP_GENERATING:
|
||||
// Placeholder for future implementation
|
||||
break;
|
||||
case Shortcut.PREVENT_FILE_CREATION:
|
||||
// This shortcut is handled by the paste event in MessageInput.svelte
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if Ctrl + Shift + O is pressed
|
||||
if (isCtrlPressed && isShiftPressed && event.key.toLowerCase() === 'o') {
|
||||
event.preventDefault();
|
||||
console.log('newChat');
|
||||
document.getElementById('sidebar-new-chat-button')?.click();
|
||||
}
|
||||
|
||||
// Check if Shift + Esc is pressed
|
||||
if (isShiftPressed && event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
console.log('focusInput');
|
||||
document.getElementById('chat-input')?.focus();
|
||||
}
|
||||
|
||||
// Check if Ctrl + Shift + ; is pressed
|
||||
if (isCtrlPressed && isShiftPressed && event.key === ';') {
|
||||
event.preventDefault();
|
||||
console.log('copyLastCodeBlock');
|
||||
const button = [...document.getElementsByClassName('copy-code-button')]?.at(-1);
|
||||
button?.click();
|
||||
}
|
||||
|
||||
// Check if Ctrl + Shift + C is pressed
|
||||
if (isCtrlPressed && isShiftPressed && event.key.toLowerCase() === 'c') {
|
||||
event.preventDefault();
|
||||
console.log('copyLastResponse');
|
||||
const button = [...document.getElementsByClassName('copy-response-button')]?.at(-1);
|
||||
console.log(button);
|
||||
button?.click();
|
||||
}
|
||||
|
||||
// Check if Ctrl + Shift + S is pressed
|
||||
if (isCtrlPressed && isShiftPressed && event.key.toLowerCase() === 's') {
|
||||
event.preventDefault();
|
||||
console.log('toggleSidebar');
|
||||
document.getElementById('sidebar-toggle-button')?.click();
|
||||
}
|
||||
|
||||
// Check if Ctrl + Shift + Backspace is pressed
|
||||
if (
|
||||
isCtrlPressed &&
|
||||
isShiftPressed &&
|
||||
(event.key === 'Backspace' || event.key === 'Delete')
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log('deleteChat');
|
||||
document.getElementById('delete-chat-button')?.click();
|
||||
}
|
||||
|
||||
// Check if Ctrl + . is pressed
|
||||
if (isCtrlPressed && event.key === '.') {
|
||||
event.preventDefault();
|
||||
console.log('openSettings');
|
||||
showSettings.set(!$showSettings);
|
||||
}
|
||||
|
||||
// Check if Ctrl + / is pressed
|
||||
if (isCtrlPressed && event.key === '/') {
|
||||
event.preventDefault();
|
||||
|
||||
showShortcuts.set(!$showShortcuts);
|
||||
}
|
||||
|
||||
// Check if Ctrl + Shift + ' is pressed
|
||||
if (
|
||||
isCtrlPressed &&
|
||||
isShiftPressed &&
|
||||
(event.key.toLowerCase() === `'` || event.key.toLowerCase() === `"`)
|
||||
) {
|
||||
event.preventDefault();
|
||||
console.log('temporaryChat');
|
||||
|
||||
if ($user?.role !== 'admin' && $user?.permissions?.chat?.temporary_enforced) {
|
||||
temporaryChatEnabled.set(true);
|
||||
} else {
|
||||
temporaryChatEnabled.set(!$temporaryChatEnabled);
|
||||
}
|
||||
|
||||
await goto('/');
|
||||
const newChatButton = document.getElementById('new-chat-button');
|
||||
setTimeout(() => {
|
||||
newChatButton?.click();
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
setupKeyboardShortcuts();
|
||||
|
||||
if ($user?.role === 'admin' && ($settings?.showChangelog ?? true)) {
|
||||
|
|
@ -445,4 +461,4 @@
|
|||
cursor: pointer;
|
||||
background-color: #bcbabb;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
Loading…
Reference in a new issue