refactor: Naming + remove redundant component

This commit is contained in:
Aleksander Grygier 2026-01-24 23:50:54 +01:00
parent f7b5f62586
commit ba230c5cce
13 changed files with 519 additions and 721 deletions

View File

@ -1,6 +1,5 @@
<script lang="ts">
import { RemoveButton } from '$lib/components/app';
import McpPromptContent from '../McpPromptContent.svelte';
import { ChatMessageMcpPromptContent, RemoveButton } from '$lib/components/app';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
interface Props {
@ -23,7 +22,7 @@
</script>
<div class="group relative {className}">
<McpPromptContent {prompt} variant="attachment" {isLoading} {loadError} />
<ChatMessageMcpPromptContent {prompt} variant="attachment" {isLoading} {loadError} />
{#if !readonly && onRemove}
<div

View File

@ -1,122 +1,418 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { ChatFormHelperText, ChatFormInputArea } from '$lib/components/app';
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormPromptPicker,
ChatFormTextarea
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { MimeTypeText } 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';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/browser-only';
import { onMount } from 'svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
class?: string;
disabled?: boolean;
initialMessage?: string;
isLoading?: boolean;
onFileRemove?: (fileId: string) => void;
onFileUpload?: (files: File[]) => void;
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
onStop?: () => void;
onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
showHelperText?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
uploadedFiles?: ChatUploadedFile[];
value?: string;
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onStop?: () => void;
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
let {
class: className,
attachments = [],
class: className = '',
disabled = false,
initialMessage = '',
isLoading = false,
onFileRemove,
onFileUpload,
onSend,
placeholder = 'Type a message...',
showMcpPromptButton = false,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onStop,
onSystemPromptAdd,
showHelperText = true,
uploadedFiles = $bindable([])
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange
}: Props = $props();
let inputAreaRef: ChatFormInputArea | undefined = $state(undefined);
let message = $state(initialMessage);
let previousIsLoading = $state(isLoading);
let previousInitialMessage = $state(initialMessage);
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let isRecording = $state(false);
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
// Sync message when initialMessage prop changes (e.g., after draft restoration)
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
export function focus() {
textareaRef?.focus();
}
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
export function resetHeight() {
textareaRef?.resetHeight();
}
async function handleSubmit() {
if (
(!message.trim() && uploadedFiles.length === 0) ||
disabled ||
isLoading ||
hasLoadingAttachments
)
return;
export function openModelSelector() {
chatFormActionsRef?.openModelSelector();
}
if (!inputAreaRef?.checkModelSelected()) return;
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
message = '';
uploadedFiles = [];
function handleFileUpload() {
fileInputRef?.click();
}
inputAreaRef?.resetHeight();
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
if (value.startsWith('/') && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
}
function handleFilesAdd(files: File[]) {
onFileUpload?.(files);
function handleKeydown(event: KeyboardEvent) {
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return;
}
if (event.key === 'Escape' && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
onSubmit?.();
}
}
function handleUploadedFileRemove(fileId: string) {
onFileRemove?.(fileId);
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
value = '';
onValueChange?.('');
isPromptPickerOpen = false;
promptSearchQuery = '';
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: 0,
type: 'mcp-prompt',
file: new File([], 'loading'),
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === 'text') {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join('\n\n');
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}.txt`, { type: 'text/plain' })
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
if (isRecording) {
try {
const audioBlob = await audioRecorder.stopRecording();
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
onFilesAdd?.([audioFile]);
isRecording = false;
} catch (error) {
console.error('Failed to stop recording:', error);
isRecording = false;
}
} else {
try {
await audioRecorder.startRecording();
isRecording = true;
} catch (error) {
console.error('Failed to start recording:', error);
}
}
}
onMount(() => {
setTimeout(() => inputAreaRef?.focus(), 10);
});
afterNavigate(() => {
setTimeout(() => inputAreaRef?.focus(), 10);
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => inputAreaRef?.focus(), 10);
}
previousIsLoading = isLoading;
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
</script>
<div class="relative mx-auto max-w-[48rem]">
<ChatFormInputArea
bind:this={inputAreaRef}
bind:value={message}
bind:uploadedFiles
class={className}
{disabled}
{isLoading}
showMcpPromptButton={true}
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove}
/>
</div>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<ChatFormHelperText show={showHelperText} />
<form
class="relative {className}"
onsubmit={(e) => {
e.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
onSubmit?.();
}}
>
<ChatFormPromptPicker
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={handlePromptPickerClose}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
/>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
>
<ChatAttachmentsList
{attachments}
bind:uploadedFiles
onFileRemove={handleFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/>
<div
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-2 py-1 md:py-0"
bind:this={textareaRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
<ChatFormActions
bind:this={chatFormActionsRef}
canSend={canSubmit}
hasText={value.trim().length > 0}
{disabled}
{isLoading}
{isRecording}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
/>
</div>
</div>
</form>

View File

@ -1,418 +0,0 @@
<script lang="ts">
import {
ChatAttachmentsList,
ChatFormActions,
ChatFormFileInputInvisible,
ChatFormPromptPicker,
ChatFormTextarea
} from '$lib/components/app';
import { INPUT_CLASSES } from '$lib/constants/css-classes';
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-config';
import { MimeTypeText } 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';
import { chatStore } from '$lib/stores/chat.svelte';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { conversationsStore, activeMessages } from '$lib/stores/conversations.svelte';
import type { GetPromptResult, MCPPromptInfo } from '$lib/types';
import { isIMEComposing, parseClipboardContent } from '$lib/utils';
import {
AudioRecorder,
convertToWav,
createAudioFile,
isAudioRecordingSupported
} from '$lib/utils/browser-only';
import { onMount } from 'svelte';
interface Props {
attachments?: DatabaseMessageExtra[];
class?: string;
disabled?: boolean;
isLoading?: boolean;
placeholder?: string;
showMcpPromptButton?: boolean;
uploadedFiles?: ChatUploadedFile[];
value?: string;
onAttachmentRemove?: (index: number) => void;
onFilesAdd?: (files: File[]) => void;
onStop?: () => void;
onSubmit?: () => void;
onSystemPromptClick?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
onUploadedFileRemove?: (fileId: string) => void;
onUploadedFilesChange?: (files: ChatUploadedFile[]) => void;
onValueChange?: (value: string) => void;
}
let {
attachments = [],
class: className = '',
disabled = false,
isLoading = false,
placeholder = 'Type a message...',
showMcpPromptButton = false,
uploadedFiles = $bindable([]),
value = $bindable(''),
onAttachmentRemove,
onFilesAdd,
onStop,
onSubmit,
onSystemPromptClick,
onUploadedFileRemove,
onUploadedFilesChange,
onValueChange
}: Props = $props();
let audioRecorder: AudioRecorder | undefined;
let chatFormActionsRef: ChatFormActions | undefined = $state(undefined);
let currentConfig = $derived(config());
let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined);
let isRecording = $state(false);
let promptPickerRef: ChatFormPromptPicker | undefined = $state(undefined);
let isPromptPickerOpen = $state(false);
let promptSearchQuery = $state('');
let recordingSupported = $state(false);
let textareaRef: ChatFormTextarea | undefined = $state(undefined);
let isRouter = $derived(isRouterMode());
let conversationModel = $derived(
chatStore.getConversationModel(activeMessages() as DatabaseMessage[])
);
let activeModelId = $derived.by(() => {
const options = modelOptions();
if (!isRouter) {
return options.length > 0 ? options[0].model : null;
}
const selectedId = selectedModelId();
if (selectedId) {
const model = options.find((m) => m.id === selectedId);
if (model) return model.model;
}
if (conversationModel) {
const model = options.find((m) => m.model === conversationModel);
if (model) return model.model;
}
return null;
});
let pasteLongTextToFileLength = $derived.by(() => {
const n = Number(currentConfig.pasteLongTextToFileLen);
return Number.isNaN(n) ? Number(SETTING_CONFIG_DEFAULT.pasteLongTextToFileLen) : n;
});
let hasModelSelected = $derived(!isRouter || !!conversationModel || !!selectedModelId());
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
let hasAttachments = $derived(
(attachments && attachments.length > 0) || (uploadedFiles && uploadedFiles.length > 0)
);
let canSubmit = $derived(value.trim().length > 0 || hasAttachments);
export function focus() {
textareaRef?.focus();
}
export function resetHeight() {
textareaRef?.resetHeight();
}
export function openModelSelector() {
chatFormActionsRef?.openModelSelector();
}
export function checkModelSelected(): boolean {
if (!hasModelSelected) {
chatFormActionsRef?.openModelSelector();
return false;
}
return true;
}
function handleFileSelect(files: File[]) {
onFilesAdd?.(files);
}
function handleFileUpload() {
fileInputRef?.click();
}
function handleInput() {
const perChatOverrides = conversationsStore.getAllMcpServerOverrides();
const hasServers = mcpStore.hasEnabledServers(perChatOverrides);
if (value.startsWith('/') && hasServers) {
isPromptPickerOpen = true;
promptSearchQuery = value.slice(1);
} else {
isPromptPickerOpen = false;
promptSearchQuery = '';
}
}
function handleKeydown(event: KeyboardEvent) {
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
return;
}
if (event.key === 'Escape' && isPromptPickerOpen) {
isPromptPickerOpen = false;
promptSearchQuery = '';
return;
}
if (event.key === 'Enter' && !event.shiftKey && !isIMEComposing(event)) {
event.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
onSubmit?.();
}
}
function handlePromptLoadStart(
placeholderId: string,
promptInfo: MCPPromptInfo,
args?: Record<string, string>
) {
value = '';
onValueChange?.('');
isPromptPickerOpen = false;
promptSearchQuery = '';
const promptName = promptInfo.title || promptInfo.name;
const placeholder: ChatUploadedFile = {
id: placeholderId,
name: promptName,
size: 0,
type: 'mcp-prompt',
file: new File([], 'loading'),
isLoading: true,
mcpPrompt: {
serverName: promptInfo.serverName,
promptName: promptInfo.name,
arguments: args ? { ...args } : undefined
}
};
uploadedFiles = [...uploadedFiles, placeholder];
onUploadedFilesChange?.(uploadedFiles);
textareaRef?.focus();
}
function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) {
const promptText = result.messages
?.map((msg) => {
if (typeof msg.content === 'string') {
return msg.content;
}
if (msg.content.type === 'text') {
return msg.content.text;
}
return '';
})
.filter(Boolean)
.join('\n\n');
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId
? {
...f,
isLoading: false,
textContent: promptText,
size: promptText.length,
file: new File([promptText], `${f.name}.txt`, { type: 'text/plain' })
}
: f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptLoadError(placeholderId: string, error: string) {
uploadedFiles = uploadedFiles.map((f) =>
f.id === placeholderId ? { ...f, isLoading: false, loadError: error } : f
);
onUploadedFilesChange?.(uploadedFiles);
}
function handlePromptPickerClose() {
isPromptPickerOpen = false;
promptSearchQuery = '';
textareaRef?.focus();
}
function handleFileRemove(fileId: string) {
if (fileId.startsWith('attachment-')) {
const index = parseInt(fileId.replace('attachment-', ''), 10);
if (!isNaN(index) && index >= 0 && index < attachments.length) {
onAttachmentRemove?.(index);
}
} else {
onUploadedFileRemove?.(fileId);
}
}
function handlePaste(event: ClipboardEvent) {
if (!event.clipboardData) return;
const files = Array.from(event.clipboardData.items)
.filter((item) => item.kind === 'file')
.map((item) => item.getAsFile())
.filter((file): file is File => file !== null);
if (files.length > 0) {
event.preventDefault();
onFilesAdd?.(files);
return;
}
const text = event.clipboardData.getData(MimeTypeText.PLAIN);
if (text.startsWith('"')) {
const parsed = parseClipboardContent(text);
if (parsed.textAttachments.length > 0) {
event.preventDefault();
value = parsed.message;
onValueChange?.(parsed.message);
const attachmentFiles = parsed.textAttachments.map(
(att) =>
new File([att.content], att.name, {
type: MimeTypeText.PLAIN
})
);
onFilesAdd?.(attachmentFiles);
setTimeout(() => {
textareaRef?.focus();
}, 10);
return;
}
}
if (
text.length > 0 &&
pasteLongTextToFileLength > 0 &&
text.length > pasteLongTextToFileLength
) {
event.preventDefault();
const textFile = new File([text], 'Pasted', {
type: MimeTypeText.PLAIN
});
onFilesAdd?.([textFile]);
}
}
async function handleMicClick() {
if (!audioRecorder || !recordingSupported) {
console.warn('Audio recording not supported');
return;
}
if (isRecording) {
try {
const audioBlob = await audioRecorder.stopRecording();
const wavBlob = await convertToWav(audioBlob);
const audioFile = createAudioFile(wavBlob);
onFilesAdd?.([audioFile]);
isRecording = false;
} catch (error) {
console.error('Failed to stop recording:', error);
isRecording = false;
}
} else {
try {
await audioRecorder.startRecording();
isRecording = true;
} catch (error) {
console.error('Failed to start recording:', error);
}
}
}
onMount(() => {
recordingSupported = isAudioRecordingSupported();
audioRecorder = new AudioRecorder();
});
</script>
<ChatFormFileInputInvisible bind:this={fileInputRef} onFileSelect={handleFileSelect} />
<form
class="relative {className}"
onsubmit={(e) => {
e.preventDefault();
if (!canSubmit || disabled || isLoading || hasLoadingAttachments) return;
onSubmit?.();
}}
>
<ChatFormPromptPicker
bind:this={promptPickerRef}
isOpen={isPromptPickerOpen}
searchQuery={promptSearchQuery}
onClose={handlePromptPickerClose}
onPromptLoadStart={handlePromptLoadStart}
onPromptLoadComplete={handlePromptLoadComplete}
onPromptLoadError={handlePromptLoadError}
/>
<div
class="{INPUT_CLASSES} overflow-hidden rounded-3xl backdrop-blur-md {disabled
? 'cursor-not-allowed opacity-60'
: ''}"
data-slot="input-area"
>
<ChatAttachmentsList
{attachments}
bind:uploadedFiles
onFileRemove={handleFileRemove}
limitToSingleRow
class="py-5"
style="scroll-padding: 1rem;"
activeModelId={activeModelId ?? undefined}
/>
<div
class="flex-column relative min-h-[48px] items-center rounded-3xl p-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:!p-3"
onpaste={handlePaste}
>
<ChatFormTextarea
class="px-2 py-1 md:py-0"
bind:this={textareaRef}
bind:value
onKeydown={handleKeydown}
onInput={() => {
handleInput();
onValueChange?.(value);
}}
{disabled}
{placeholder}
/>
<ChatFormActions
bind:this={chatFormActionsRef}
canSend={canSubmit}
hasText={value.trim().length > 0}
{disabled}
{isLoading}
{isRecording}
{uploadedFiles}
onFileUpload={handleFileUpload}
onMicClick={handleMicClick}
{onStop}
onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })}
onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined}
/>
</div>
</div>
</form>

View File

@ -2,7 +2,7 @@
import { X, AlertTriangle } from '@lucide/svelte';
import { Button } from '$lib/components/ui/button';
import { Switch } from '$lib/components/ui/switch';
import { ChatFormInputArea, DialogConfirmation } from '$lib/components/app';
import { ChatForm, DialogConfirmation } from '$lib/components/app';
import { chatStore } from '$lib/stores/chat.svelte';
import { processFilesToChatUploaded } from '$lib/utils/browser-only';
@ -36,7 +36,7 @@
onEditedUploadedFilesChange
}: Props = $props();
let inputAreaRef: ChatFormInputArea | undefined = $state(undefined);
let inputAreaRef: ChatForm | undefined = $state(undefined);
let saveWithoutRegenerate = $state(false);
let showDiscardDialog = $state(false);
@ -126,7 +126,7 @@
<svelte:window onkeydown={handleGlobalKeydown} />
<div class="relative w-full max-w-[80%]">
<ChatFormInputArea
<ChatForm
bind:this={inputAreaRef}
value={editedContent}
attachments={editedExtras}

View File

@ -1,6 +1,5 @@
<script lang="ts">
import ChatMessageActions from './ChatMessageActions.svelte';
import McpPromptContent from '../McpPromptContent.svelte';
import { ChatMessageActions, ChatMessageMcpPromptContent } from '$lib/components/app';
import { MessageRole } from '$lib/enums';
import type { DatabaseMessageExtraMcpPrompt } from '$lib/types';
@ -45,7 +44,7 @@
class="group flex flex-col items-end gap-3 md:gap-2 {className}"
role="group"
>
<McpPromptContent prompt={mcpPrompt} variant="message" class="w-full max-w-[80%]" />
<ChatMessageMcpPromptContent prompt={mcpPrompt} variant="message" class="w-full max-w-[80%]" />
{#if message.timestamp}
<div class="max-w-[80%]">

View File

@ -1,7 +1,7 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import {
ChatForm,
ChatScreenForm,
ChatScreenHeader,
ChatMessages,
ChatScreenProcessingInfo,
@ -391,7 +391,7 @@
{/if}
<div class="conversation-chat-form pointer-events-auto rounded-t-3xl pb-4">
<ChatForm
<ChatScreenForm
disabled={hasPropsError || isEditing()}
{initialMessage}
isLoading={isCurrentConversationLoading}
@ -451,7 +451,7 @@
{/if}
<div in:fly={{ y: 10, duration: 250, delay: hasPropsError ? 0 : 300 }}>
<ChatForm
<ChatScreenForm
disabled={hasPropsError}
{initialMessage}
isLoading={isCurrentConversationLoading}

View File

@ -0,0 +1,122 @@
<script lang="ts">
import { afterNavigate } from '$app/navigation';
import { ChatFormHelperText, ChatForm } from '$lib/components/app';
import { onMount } from 'svelte';
interface Props {
class?: string;
disabled?: boolean;
initialMessage?: string;
isLoading?: boolean;
onFileRemove?: (fileId: string) => void;
onFileUpload?: (files: File[]) => void;
onSend?: (message: string, files?: ChatUploadedFile[]) => Promise<boolean>;
onStop?: () => void;
onSystemPromptAdd?: (draft: { message: string; files: ChatUploadedFile[] }) => void;
showHelperText?: boolean;
uploadedFiles?: ChatUploadedFile[];
}
let {
class: className,
disabled = false,
initialMessage = '',
isLoading = false,
onFileRemove,
onFileUpload,
onSend,
onStop,
onSystemPromptAdd,
showHelperText = true,
uploadedFiles = $bindable([])
}: Props = $props();
let inputAreaRef: ChatForm | undefined = $state(undefined);
let message = $state(initialMessage);
let previousIsLoading = $state(isLoading);
let previousInitialMessage = $state(initialMessage);
// Sync message when initialMessage prop changes (e.g., after draft restoration)
$effect(() => {
if (initialMessage !== previousInitialMessage) {
message = initialMessage;
previousInitialMessage = initialMessage;
}
});
function handleSystemPromptClick() {
onSystemPromptAdd?.({ message, files: uploadedFiles });
}
let hasLoadingAttachments = $derived(uploadedFiles.some((f) => f.isLoading));
async function handleSubmit() {
if (
(!message.trim() && uploadedFiles.length === 0) ||
disabled ||
isLoading ||
hasLoadingAttachments
)
return;
if (!inputAreaRef?.checkModelSelected()) return;
const messageToSend = message.trim();
const filesToSend = [...uploadedFiles];
message = '';
uploadedFiles = [];
inputAreaRef?.resetHeight();
const success = await onSend?.(messageToSend, filesToSend);
if (!success) {
message = messageToSend;
uploadedFiles = filesToSend;
}
}
function handleFilesAdd(files: File[]) {
onFileUpload?.(files);
}
function handleUploadedFileRemove(fileId: string) {
onFileRemove?.(fileId);
}
onMount(() => {
setTimeout(() => inputAreaRef?.focus(), 10);
});
afterNavigate(() => {
setTimeout(() => inputAreaRef?.focus(), 10);
});
$effect(() => {
if (previousIsLoading && !isLoading) {
setTimeout(() => inputAreaRef?.focus(), 10);
}
previousIsLoading = isLoading;
});
</script>
<div class="relative mx-auto max-w-[48rem]">
<ChatForm
bind:this={inputAreaRef}
bind:value={message}
bind:uploadedFiles
class={className}
{disabled}
{isLoading}
showMcpPromptButton={true}
onFilesAdd={handleFilesAdd}
{onStop}
onSubmit={handleSubmit}
onSystemPromptClick={handleSystemPromptClick}
onUploadedFileRemove={handleUploadedFileRemove}
/>
</div>
<ChatFormHelperText show={showHelperText} />

View File

@ -16,7 +16,7 @@
ChatSettingsImportExportTab,
ChatSettingsFields
} from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
import { ScrollArea } from '$lib/components/ui/scroll-area';
import { config, settingsStore } from '$lib/stores/settings.svelte';
import { setMode } from 'mode-watcher';

View File

@ -14,7 +14,6 @@ export { default as ChatFormActions } from './chat/ChatForm/ChatFormActions/Chat
export { default as ChatFormActionSubmit } from './chat/ChatForm/ChatFormActions/ChatFormActionSubmit.svelte';
export { default as ChatFormFileInputInvisible } from './chat/ChatForm/ChatFormFileInputInvisible.svelte';
export { default as ChatFormHelperText } from './chat/ChatForm/ChatFormHelperText.svelte';
export { default as ChatFormInputArea } from './chat/ChatForm/ChatFormInputArea.svelte';
export { default as ChatFormPromptPicker } from './chat/ChatForm/ChatFormPromptPicker.svelte';
export { default as ChatFormTextarea } from './chat/ChatForm/ChatFormTextarea.svelte';
@ -24,15 +23,15 @@ export { default as ChatMessageActions } from './chat/ChatMessages/ChatMessageAc
export { default as ChatMessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as ChatMessageStatistics } from './chat/ChatMessages/ChatMessageStatistics.svelte';
export { default as ChatMessageMcpPrompt } from './chat/ChatMessages/ChatMessageMcpPrompt.svelte';
export { default as ChatMessageMcpPromptContent } from './chat/ChatMessages/ChatMessageMcpPromptContent.svelte';
export { default as ChatMessageSystem } from './chat/ChatMessages/ChatMessageSystem.svelte';
export { default as ChatMessages } from './chat/ChatMessages/ChatMessages.svelte';
export { default as CollapsibleContentBlock } from './chat/ChatMessages/CollapsibleContentBlock.svelte';
export { default as MessageBranchingControls } from './chat/ChatMessages/ChatMessageBranchingControls.svelte';
export { default as McpPromptContent } from './chat/McpPromptContent.svelte';
export { default as ChatScreen } from './chat/ChatScreen/ChatScreen.svelte';
export { default as KeyValuePairs } from './misc/KeyValuePairs.svelte';
export { default as ChatScreenForm } from './chat/ChatScreen/ChatScreenForm.svelte';
export { default as ChatScreenHeader } from './chat/ChatScreen/ChatScreenHeader.svelte';
export { default as ChatScreenProcessingInfo } from './chat/ChatScreen/ChatScreenProcessingInfo.svelte';
@ -66,11 +65,11 @@ export { default as ActionButton } from './misc/ActionButton.svelte';
export { default as ActionDropdown } from './misc/ActionDropdown.svelte';
export { default as BadgeChatStatistic } from './misc/BadgeChatStatistic.svelte';
export { default as BadgeInfo } from './misc/BadgeInfo.svelte';
export { default as McpLogo } from './misc/McpLogo.svelte';
export { default as BadgeModality } from './misc/BadgeModality.svelte';
export { default as ConversationSelection } from './misc/ConversationSelection.svelte';
export { default as CopyToClipboardIcon } from './misc/CopyToClipboardIcon.svelte';
export { default as KeyboardShortcutInfo } from './misc/KeyboardShortcutInfo.svelte';
export { default as KeyValuePairs } from './misc/KeyValuePairs.svelte';
export { default as MarkdownContent } from './misc/MarkdownContent.svelte';
export { default as RemoveButton } from './misc/RemoveButton.svelte';
export { default as SearchInput } from './misc/SearchInput.svelte';
@ -86,7 +85,7 @@ export { default as ModelsSelector } from './models/ModelsSelector.svelte';
// MCP
export { default as McpActiveServersAvatars } from './mcp/McpActiveServersAvatars.svelte';
export { default as McpSelector } from './mcp/McpSelector.svelte';
export { default as McpLogo } from './mcp/McpLogo.svelte';
export { default as McpSettingsSection } from './mcp/McpSettingsSection.svelte';
// Server

View File

@ -1,199 +0,0 @@
<script lang="ts">
import { ChevronDown, Settings } from '@lucide/svelte';
import { Skeleton } from '$lib/components/ui/skeleton';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { Switch } from '$lib/components/ui/switch';
import { cn } from '$lib/components/ui/utils';
import { SearchableDropdownMenu } from '$lib/components/app';
import McpLogo from '$lib/components/app/misc/McpLogo.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { getFaviconUrl } from '$lib/utils';
import type { MCPServerSettingsEntry } from '$lib/types';
import { HealthCheckStatus } from '$lib/enums';
import { mcpStore } from '$lib/stores/mcp.svelte';
import { mcpClient } from '$lib/clients/mcp.client';
interface Props {
class?: string;
disabled?: boolean;
onSettingsClick?: () => void;
}
let { class: className = '', disabled = false, onSettingsClick }: Props = $props();
let searchQuery = $state('');
let mcpServers = $derived(mcpStore.getServersSorted().filter((s) => s.enabled));
let hasMcpServers = $derived(mcpServers.length > 0);
let isLoading = $derived(mcpStore.isAnyServerLoading());
let enabledMcpServersForChat = $derived(
mcpServers.filter((s) => isServerEnabledForChat(s.id) && s.url.trim())
);
let healthyEnabledMcpServers = $derived(
enabledMcpServersForChat.filter((s) => {
const healthState = mcpStore.getHealthCheckState(s.id);
return healthState.status !== 'error';
})
);
let hasEnabledMcpServers = $derived(enabledMcpServersForChat.length > 0);
let extraServersCount = $derived(Math.max(0, healthyEnabledMcpServers.length - 3));
let filteredMcpServers = $derived.by(() => {
const query = searchQuery.toLowerCase().trim();
if (query) {
return mcpServers.filter((s) => {
const name = getServerLabel(s).toLowerCase();
const url = s.url.toLowerCase();
return name.includes(query) || url.includes(query);
});
}
return mcpServers;
});
let mcpFavicons = $derived(
healthyEnabledMcpServers
.slice(0, 3)
.map((s) => ({ id: s.id, url: getFaviconUrl(s.url) }))
.filter((f) => f.url !== null)
);
function getServerLabel(server: MCPServerSettingsEntry): string {
return mcpStore.getServerLabel(server);
}
function handleDropdownOpen(open: boolean) {
if (open) {
mcpClient.runHealthChecksForServers(mcpServers);
}
}
function isServerEnabledForChat(serverId: string): boolean {
return conversationsStore.isMcpServerEnabledForChat(serverId);
}
async function toggleServerForChat(serverId: string) {
await conversationsStore.toggleMcpServerForChat(serverId);
}
</script>
{#if hasMcpServers}
<SearchableDropdownMenu
bind:searchValue={searchQuery}
placeholder="Search servers..."
emptyMessage="No servers found"
isEmpty={filteredMcpServers.length === 0}
{disabled}
onOpenChange={handleDropdownOpen}
>
{#snippet trigger()}
<button
type="button"
class={cn(
'inline-flex cursor-pointer items-center rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
hasEnabledMcpServers ? 'text-foreground' : 'text-muted-foreground',
className
)}
{disabled}
aria-label="MCP Servers"
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
<span class="mx-1.5 font-medium">MCP</span>
{#if hasEnabledMcpServers && mcpFavicons.length > 0}
<div class="flex -space-x-1">
{#each mcpFavicons as favicon (favicon.id)}
<img
src={favicon.url}
alt=""
class="h-3.5 w-3.5 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/each}
</div>
{#if extraServersCount > 0}
<span class="ml-1 text-muted-foreground">+{extraServersCount}</span>
{/if}
{/if}
<ChevronDown class="h-3 w-3.5" />
</button>
{/snippet}
<div class="max-h-64 overflow-y-auto">
{#if isLoading}
{#each mcpServers as server (server.id)}
<div class="flex items-center justify-between gap-2 px-2 py-2">
<div class="flex min-w-0 flex-1 items-center gap-2">
<Skeleton class="h-4 w-4 shrink-0 rounded-sm" />
<Skeleton class="h-4 w-24" />
</div>
<Skeleton class="h-5 w-9 rounded-full" />
</div>
{/each}
{:else}
{#each filteredMcpServers as server (server.id)}
{@const healthState = mcpStore.getHealthCheckState(server.id)}
{@const hasError = healthState.status === HealthCheckStatus.Error}
{@const isEnabledForChat = isServerEnabledForChat(server.id)}
<button
type="button"
class="flex w-full items-center justify-between gap-2 px-2 py-2 text-left transition-colors hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50"
onclick={() => !hasError && toggleServerForChat(server.id)}
disabled={hasError}
>
<div class="flex min-w-0 flex-1 items-center gap-2">
{#if getFaviconUrl(server.url)}
<img
src={getFaviconUrl(server.url)}
alt=""
class="h-4 w-4 shrink-0 rounded-sm"
onerror={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
{/if}
<span class="truncate text-sm">{getServerLabel(server)}</span>
{#if hasError}
<span
class="shrink-0 rounded bg-destructive/15 px-1.5 py-0.5 text-xs text-destructive"
>Error</span
>
{/if}
</div>
<Switch
checked={isEnabledForChat}
disabled={hasError}
onclick={(e: MouseEvent) => e.stopPropagation()}
onCheckedChange={() => toggleServerForChat(server.id)}
/>
</button>
{/each}
{/if}
</div>
{#snippet footer()}
<DropdownMenu.Item class="flex cursor-pointer items-center gap-2" onclick={onSettingsClick}>
<Settings class="h-4 w-4" />
<span>Manage MCP Servers</span>
</DropdownMenu.Item>
{/snippet}
</SearchableDropdownMenu>
{:else}
<button
type="button"
class={cn(
'inline-flex cursor-pointer items-center rounded-sm bg-muted-foreground/10 px-1.5 py-1 text-xs transition hover:text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60',
'text-muted-foreground',
className
)}
{disabled}
aria-label="MCP Servers"
onclick={onSettingsClick}
>
<McpLogo style="width: 0.875rem; height: 0.875rem;" />
<span class="mx-1.5 font-medium">MCP</span>
</button>
{/if}

View File

@ -1,14 +1,14 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
import { expect } from 'storybook/test';
import jpgAsset from './fixtures/assets/1.jpg?url';
import svgAsset from './fixtures/assets/hf-logo.svg?url';
import pdfAsset from './fixtures/assets/example.pdf?raw';
const { Story } = defineMeta({
title: 'Components/ChatScreen/ChatForm',
component: ChatForm,
title: 'Components/ChatScreen/ChatScreenForm',
component: ChatScreenForm,
parameters: {
layout: 'centered'
}