diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index c52a33dfcf..6b58b27390 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -10,7 +10,8 @@ } from '$lib/components/app'; import { INPUT_CLASSES } from '$lib/constants/css-classes'; import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config'; - import { MimeTypeText, SpecialFileType } from '$lib/enums'; + import { INITIAL_FILE_SIZE, PROMPT_CONTENT_SEPARATOR } from '$lib/constants/chat-form'; + import { ContentPartType, KeyboardKey, MimeTypeText, SpecialFileType } from '$lib/enums'; import { config } from '$lib/stores/settings.svelte'; import { modelOptions, selectedModelId } from '$lib/stores/models.svelte'; import { isRouterMode } from '$lib/stores/server.svelte'; @@ -232,13 +233,13 @@ return; } - if (event.key === 'Escape' && isPromptPickerOpen) { + if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) { isPromptPickerOpen = false; promptSearchQuery = ''; return; } - if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) { + if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { event.preventDefault(); if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return; @@ -289,7 +290,7 @@ name: att.name, size: att.content.length, type: SpecialFileType.MCP_PROMPT, - file: new File([att.content], `${att.name}.txt`, { type: 'text/plain' }), + file: new File([att.content], `${att.name}.txt`, { type: MimeTypeText.PLAIN }), isLoading: false, textContent: att.content, mcpPrompt: { @@ -348,7 +349,7 @@ const placeholder: ChatUploadedFile = { id: placeholderId, name: promptName, - size: 0, + size: INITIAL_FILE_SIZE, type: SpecialFileType.MCP_PROMPT, file: new File([], 'loading'), isLoading: true, @@ -370,13 +371,13 @@ if (typeof msg.content === 'string') { return msg.content; } - if (msg.content.type === 'text') { + if (msg.content.type === ContentPartType.TEXT) { return msg.content.text; } return ''; }) .filter(Boolean) - .join('\n\n'); + .join(PROMPT_CONTENT_SEPARATOR); uploadedFiles = uploadedFiles.map((f) => f.id === placeholderId @@ -385,7 +386,7 @@ isLoading: false, textContent: promptText, size: promptText.length, - file: new File([promptText], `${f.name}.txt`, { type: 'text/plain' }) + file: new File([promptText], `${f.name}.txt`, { type: MimeTypeText.PLAIN }) } : f ); diff --git a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte index 4737c5b422..74cbcf9c8c 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatForm/ChatFormPromptPicker/ChatFormPromptPicker.svelte @@ -2,6 +2,7 @@ import { conversationsStore } from '$lib/stores/conversations.svelte'; import { mcpStore } from '$lib/stores/mcp.svelte'; import { debounce } from '$lib/utils'; + import { KeyboardKey } from '$lib/enums'; import type { MCPPromptInfo, GetPromptResult, MCPServerSettingsEntry } from '$lib/types'; import { SvelteMap } from 'svelte/reactivity'; import ChatFormPromptPickerList from './ChatFormPromptPickerList.svelte'; @@ -213,17 +214,17 @@ if (argSuggestions.length === 0 || activeAutocomplete !== argName) return; - if (event.key === 'ArrowDown') { + if (event.key === KeyboardKey.ARROW_DOWN) { event.preventDefault(); autocompleteIndex = Math.min(autocompleteIndex + 1, argSuggestions.length - 1); - } else if (event.key === 'ArrowUp') { + } else if (event.key === KeyboardKey.ARROW_UP) { event.preventDefault(); autocompleteIndex = Math.max(autocompleteIndex - 1, 0); - } else if (event.key === 'Enter' && argSuggestions[autocompleteIndex]) { + } else if (event.key === KeyboardKey.ENTER && argSuggestions[autocompleteIndex]) { event.preventDefault(); event.stopPropagation(); selectSuggestion(argName, argSuggestions[autocompleteIndex]); - } else if (event.key === 'Escape') { + } else if (event.key === KeyboardKey.ESCAPE) { event.preventDefault(); suggestions[argName] = []; activeAutocomplete = null; @@ -255,7 +256,7 @@ export function handleKeydown(event: KeyboardEvent): boolean { if (!isOpen) return false; - if (event.key === 'Escape') { + if (event.key === KeyboardKey.ESCAPE) { event.preventDefault(); if (selectedPrompt) { selectedPrompt = null; @@ -267,7 +268,7 @@ return true; } - if (event.key === 'ArrowDown') { + if (event.key === KeyboardKey.ARROW_DOWN) { event.preventDefault(); if (selectedIndex < filteredPrompts.length - 1) { selectedIndex++; @@ -276,7 +277,7 @@ return true; } - if (event.key === 'ArrowUp') { + if (event.key === KeyboardKey.ARROW_UP) { event.preventDefault(); if (selectedIndex > 0) { selectedIndex--; @@ -285,7 +286,7 @@ return true; } - if (event.key === 'Enter' && !selectedPrompt) { + if (event.key === KeyboardKey.ENTER && !selectedPrompt) { event.preventDefault(); if (filteredPrompts[selectedIndex]) { handlePromptClick(filteredPrompts[selectedIndex]); diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte index c0d6edc254..68e3a9c635 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte @@ -6,8 +6,9 @@ } from '$lib/components/app'; import { config } from '$lib/stores/settings.svelte'; import { Wrench, Loader2, AlertTriangle, Brain } from '@lucide/svelte'; - import { AgenticSectionType, AttachmentType } from '$lib/enums'; + import { AgenticSectionType, AttachmentType, FileTypeText } from '$lib/enums'; import { formatJsonPretty } from '$lib/utils'; + import { ATTACHMENT_SAVED_REGEX } from '$lib/constants/agentic-ui'; import { parseAgenticContent, type AgenticSection } from '$lib/utils/agentic'; import type { DatabaseMessage, DatabaseMessageExtraImageFile } from '$lib/types/database'; @@ -78,7 +79,7 @@ ): ToolResultLine[] { const lines = toolResult.split('\n'); return lines.map((line) => { - const match = line.match(/\[Attachment saved: ([^\]]+)\]/); + const match = line.match(ATTACHMENT_SAVED_REGEX); if (!match || !extras) return { text: line }; const attachmentName = match[1]; @@ -124,7 +125,7 @@ {#if section.toolArgs} @@ -162,7 +163,7 @@ diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte index f9dd254b50..cb62af48e6 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageAssistant.svelte @@ -17,7 +17,7 @@ import { Button } from '$lib/components/ui/button'; import { Checkbox } from '$lib/components/ui/checkbox'; import { INPUT_CLASSES } from '$lib/constants/css-classes'; - import { MessageRole } from '$lib/enums'; + import { MessageRole, KeyboardKey } from '$lib/enums'; import Label from '$lib/components/ui/label/label.svelte'; import { config } from '$lib/stores/settings.svelte'; import { isRouterMode } from '$lib/stores/server.svelte'; @@ -75,10 +75,10 @@ let shouldBranchAfterEdit = $state(false); function handleEditKeydown(event: KeyboardEvent) { - if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) { + if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { event.preventDefault(); editCtx.save(); - } else if (event.key === 'Escape') { + } else if (event.key === KeyboardKey.ESCAPE) { event.preventDefault(); editCtx.cancel(); } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte index ed632f7ef6..5503ad1fab 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageEditForm.svelte @@ -4,6 +4,7 @@ import { Switch } from '$lib/components/ui/switch'; import { ChatForm, DialogConfirmation } from '$lib/components/app'; import { getMessageEditContext } from '$lib/contexts'; + import { KeyboardKey } from '$lib/enums'; import { chatStore } from '$lib/stores/chat.svelte'; import { processFilesToChatUploaded } from '$lib/utils/browser-only'; @@ -34,7 +35,7 @@ let canSubmit = $derived(editCtx.editedContent.trim().length > 0 || hasAttachments); function handleGlobalKeydown(event: KeyboardEvent) { - if (event.key === 'Escape') { + if (event.key === KeyboardKey.ESCAPE) { event.preventDefault(); attemptCancel(); } diff --git a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte index c18babc243..c959fc3e53 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatMessages/ChatMessageSystem.svelte @@ -8,7 +8,7 @@ import { config } from '$lib/stores/settings.svelte'; import { isIMEComposing } from '$lib/utils'; import ChatMessageActions from './ChatMessageActions.svelte'; - import { MessageRole } from '$lib/enums'; + import { KeyboardKey, MessageRole } from '$lib/enums'; interface Props { class?: string; @@ -48,11 +48,11 @@ const editCtx = getMessageEditContext(); function handleEditKeydown(event: KeyboardEvent) { - if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) { + if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { event.preventDefault(); editCtx.save(); - } else if (event.key === 'Escape') { + } else if (event.key === KeyboardKey.ESCAPE) { event.preventDefault(); editCtx.cancel(); diff --git a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte index c7cc1eecd9..ceecf03e54 100644 --- a/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte +++ b/tools/server/webui/src/lib/components/app/chat/ChatScreen/ChatScreen.svelte @@ -13,6 +13,7 @@ import * as Alert from '$lib/components/ui/alert'; import * as AlertDialog from '$lib/components/ui/alert-dialog'; import { INITIAL_SCROLL_DELAY } from '$lib/constants/auto-scroll'; + import { KeyboardKey } from '$lib/enums'; import { createAutoScrollController } from '$lib/hooks/use-auto-scroll.svelte'; import { chatStore, @@ -211,7 +212,11 @@ function handleKeydown(event: KeyboardEvent) { const isCtrlOrCmd = event.ctrlKey || event.metaKey; - if (isCtrlOrCmd && event.shiftKey && (event.key === 'd' || event.key === 'D')) { + if ( + isCtrlOrCmd && + event.shiftKey && + (event.key === KeyboardKey.D_LOWER || event.key === KeyboardKey.D_UPPER) + ) { event.preventDefault(); if (activeConversation()) { showDeleteDialog = true; diff --git a/tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte b/tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte index a87fd87e27..3763659902 100644 --- a/tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte +++ b/tools/server/webui/src/lib/components/app/content/MarkdownContent.svelte @@ -18,6 +18,13 @@ import { rehypeResolveAttachmentImages } from '$lib/markdown/resolve-attachment-images'; import { remarkLiteralHtml } from '$lib/markdown/literal-html'; import { copyCodeToClipboard, preprocessLaTeX, getImageErrorFallbackHtml } from '$lib/utils'; + import { + IMAGE_NOT_ERROR_BOUND_SELECTOR, + DATA_ERROR_BOUND_ATTR, + DATA_ERROR_HANDLED_ATTR, + BOOL_TRUE_STRING + } from '$lib/constants/markdown'; + import { UrlPrefix } from '$lib/enums'; import { highlightCode, detectIncompleteCodeBlock, @@ -470,10 +477,10 @@ function setupImageErrorHandlers() { if (!containerRef) return; - const images = containerRef.querySelectorAll('img:not([data-error-bound])'); + const images = containerRef.querySelectorAll(IMAGE_NOT_ERROR_BOUND_SELECTOR); for (const img of images) { - img.dataset.errorBound = 'true'; + img.dataset[DATA_ERROR_BOUND_ATTR] = BOOL_TRUE_STRING; img.addEventListener('error', handleImageError); } } @@ -487,8 +494,12 @@ if (!img || !img.src) return; // Don't handle data URLs or already-handled images - if (img.src.startsWith('data:') || img.dataset.errorHandled === 'true') return; - img.dataset.errorHandled = 'true'; + if ( + img.src.startsWith(UrlPrefix.DATA) || + img.dataset[DATA_ERROR_HANDLED_ATTR] === BOOL_TRUE_STRING + ) + return; + img.dataset[DATA_ERROR_HANDLED_ATTR] = BOOL_TRUE_STRING; const src = img.src; // Create fallback element diff --git a/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte b/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte index b5175a9925..d8aa66f3e8 100644 --- a/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte +++ b/tools/server/webui/src/lib/components/app/dialogs/DialogConfirmation.svelte @@ -1,6 +1,7 @@ + + +
+
+ + + +
+ +
+ +
+ + + +
+ +
+ + +
+ + + +
+ + + +
+
diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte index 9cf4edf951..7bc547c05a 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpServerForm.svelte @@ -4,6 +4,8 @@ import { KeyValuePairs } from '$lib/components/app'; import type { KeyValuePair } from '$lib/types'; import { parseHeadersToArray, serializeHeaders } from '$lib/utils'; + import { UrlPrefix } from '$lib/enums'; + import { MCP_SERVER_URL_PLACEHOLDER } from '$lib/constants/mcp-form'; interface Props { url: string; @@ -28,7 +30,8 @@ }: Props = $props(); let isWebSocket = $derived( - url.toLowerCase().startsWith('ws://') || url.toLowerCase().startsWith('wss://') + url.toLowerCase().startsWith(UrlPrefix.WEBSOCKET) || + url.toLowerCase().startsWith(UrlPrefix.WEBSOCKET_SECURE) ); let headerPairs = $derived(parseHeadersToArray(headers)); @@ -48,7 +51,7 @@ onUrlChange(e.currentTarget.value)} class={urlError ? 'border-destructive' : ''} diff --git a/tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte b/tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte index c8c582b81b..9c6ce42289 100644 --- a/tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte +++ b/tools/server/webui/src/lib/components/app/mcp/McpServersSettings.svelte @@ -5,8 +5,7 @@ import { getFaviconUrl } from '$lib/utils'; import { mcpStore } from '$lib/stores/mcp.svelte'; import { conversationsStore } from '$lib/stores/conversations.svelte'; - import { McpServerCard, McpServerForm } from '$lib/components/app/mcp'; - import { Skeleton } from '$lib/components/ui/skeleton'; + import { McpServerCard, McpServerCardSkeleton, McpServerForm } from '$lib/components/app/mcp'; let servers = $derived(mcpStore.getServersSorted()); @@ -135,35 +134,7 @@
{#each servers as server (server.id)} {#if !initialLoadComplete} - -
-
- - - -
- -
- -
- - - -
- -
- - -
- - - -
- - - -
-
+ {:else} = 0 && highlightedIndex < filteredOptions.length) { const option = filteredOptions[highlightedIndex]; diff --git a/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte b/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte index fa4c2842cc..c7f52a7c58 100644 --- a/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte +++ b/tools/server/webui/src/lib/components/app/server/ServerErrorSplash.svelte @@ -8,6 +8,7 @@ import { serverStore, serverLoading } from '$lib/stores/server.svelte'; import { config, settingsStore } from '$lib/stores/settings.svelte'; import { fade, fly, scale } from 'svelte/transition'; + import { KeyboardKey } from '$lib/enums'; interface Props { class?: string; @@ -117,7 +118,7 @@ } function handleApiKeyKeydown(event: KeyboardEvent) { - if (event.key === 'Enter') { + if (event.key === KeyboardKey.ENTER) { handleSaveApiKey(); } } diff --git a/tools/server/webui/src/lib/constants/agentic-ui.ts b/tools/server/webui/src/lib/constants/agentic-ui.ts new file mode 100644 index 0000000000..a4b9baa272 --- /dev/null +++ b/tools/server/webui/src/lib/constants/agentic-ui.ts @@ -0,0 +1 @@ +export const ATTACHMENT_SAVED_REGEX = /\[Attachment saved: ([^\]]+)\]/; diff --git a/tools/server/webui/src/lib/constants/chat-form.ts b/tools/server/webui/src/lib/constants/chat-form.ts new file mode 100644 index 0000000000..c4aa6b7ef9 --- /dev/null +++ b/tools/server/webui/src/lib/constants/chat-form.ts @@ -0,0 +1,2 @@ +export const INITIAL_FILE_SIZE = 0; +export const PROMPT_CONTENT_SEPARATOR = '\n\n'; diff --git a/tools/server/webui/src/lib/constants/markdown.ts b/tools/server/webui/src/lib/constants/markdown.ts new file mode 100644 index 0000000000..783d31a22c --- /dev/null +++ b/tools/server/webui/src/lib/constants/markdown.ts @@ -0,0 +1,4 @@ +export const IMAGE_NOT_ERROR_BOUND_SELECTOR = 'img:not([data-error-bound])'; +export const DATA_ERROR_BOUND_ATTR = 'errorBound'; +export const DATA_ERROR_HANDLED_ATTR = 'errorHandled'; +export const BOOL_TRUE_STRING = 'true'; diff --git a/tools/server/webui/src/lib/constants/mcp-form.ts b/tools/server/webui/src/lib/constants/mcp-form.ts new file mode 100644 index 0000000000..5a3e8f1dca --- /dev/null +++ b/tools/server/webui/src/lib/constants/mcp-form.ts @@ -0,0 +1 @@ +export const MCP_SERVER_URL_PLACEHOLDER = 'https://mcp.example.com/sse'; diff --git a/tools/server/webui/src/lib/enums/index.ts b/tools/server/webui/src/lib/enums/index.ts index 0e8504cc1d..051fa58a54 100644 --- a/tools/server/webui/src/lib/enums/index.ts +++ b/tools/server/webui/src/lib/enums/index.ts @@ -44,3 +44,5 @@ export { ServerRole, ServerModelStatus } from './server'; export { ParameterSource, SyncableParameterType } from './settings'; export { ColorMode, McpPromptVariant, UrlPrefix } from './ui'; + +export { KeyboardKey } from './keyboard'; diff --git a/tools/server/webui/src/lib/enums/keyboard.ts b/tools/server/webui/src/lib/enums/keyboard.ts new file mode 100644 index 0000000000..b8f6d5f7a2 --- /dev/null +++ b/tools/server/webui/src/lib/enums/keyboard.ts @@ -0,0 +1,15 @@ +/** + * Keyboard key names for event handling + */ +export enum KeyboardKey { + ENTER = 'Enter', + ESCAPE = 'Escape', + ARROW_UP = 'ArrowUp', + ARROW_DOWN = 'ArrowDown', + TAB = 'Tab', + D_LOWER = 'd', + D_UPPER = 'D', + E_UPPER = 'E', + K_LOWER = 'k', + O_UPPER = 'O' +} diff --git a/tools/server/webui/src/routes/+layout.svelte b/tools/server/webui/src/routes/+layout.svelte index 095827b9ca..705066119d 100644 --- a/tools/server/webui/src/routes/+layout.svelte +++ b/tools/server/webui/src/routes/+layout.svelte @@ -15,6 +15,7 @@ import { goto } from '$app/navigation'; import { modelsStore } from '$lib/stores/models.svelte'; import { TOOLTIP_DELAY_DURATION } from '$lib/constants/tooltip-config'; + import { KeyboardKey } from '$lib/enums'; import { IsMobile } from '$lib/hooks/is-mobile.svelte'; let { children } = $props(); @@ -43,7 +44,7 @@ function handleKeydown(event: KeyboardEvent) { const isCtrlOrCmd = event.ctrlKey || event.metaKey; - if (isCtrlOrCmd && event.key === 'k') { + if (isCtrlOrCmd && event.key === KeyboardKey.K_LOWER) { event.preventDefault(); if (chatSidebar?.activateSearchMode) { chatSidebar.activateSearchMode(); @@ -51,12 +52,12 @@ } } - if (isCtrlOrCmd && event.shiftKey && event.key === 'O') { + if (isCtrlOrCmd && event.shiftKey && event.key === KeyboardKey.O_UPPER) { event.preventDefault(); goto('?new_chat=true#/'); } - if (event.shiftKey && isCtrlOrCmd && event.key === 'E') { + if (event.shiftKey && isCtrlOrCmd && event.key === KeyboardKey.E_UPPER) { event.preventDefault(); if (chatSidebar?.editActiveConversation) {