Files
vessel/frontend/src/lib/components/chat/ChatInput.svelte
vikingowl d54acb97a3 fix: improve light mode theming and text contrast
- Increase light mode text contrast in app.css (slate.600→700, slate.500→600)
- Add light/dark mode prose styles in MessageContent.svelte for proper markdown rendering
- Convert hardcoded slate-* classes to theme utilities across 37 components
- Fix code block copy button and scrollbar theming for both modes
- Update all route pages (models, tools, knowledge, prompts) with theme classes
- Ensure consistent theming in modals, dialogs, and form inputs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 06:20:48 +01:00

426 lines
13 KiB
Svelte

<script lang="ts">
/**
* ChatInput - Message input area with auto-growing textarea
* Handles send/stop actions, keyboard shortcuts, and file uploads
* Drag overlay appears when files are dragged over the input area
*/
import { modelsState } from '$lib/stores';
import type { FileAttachment } from '$lib/types/attachment.js';
import { formatAttachmentsForMessage, processFile } from '$lib/utils/file-processor.js';
import { isImageMimeType } from '$lib/types/attachment.js';
import { estimateMessageTokens, formatTokenCount } from '$lib/memory/tokenizer';
import FileUpload from './FileUpload.svelte';
interface Props {
onSend?: (content: string, images?: string[]) => void;
onStop?: () => void;
isStreaming?: boolean;
disabled?: boolean;
placeholder?: string;
/** Hide the attach button in FileUpload (when shown elsewhere) */
hideAttachButton?: boolean;
/** Bindable function to trigger file picker from parent */
triggerFilePicker?: () => void;
}
let {
onSend,
onStop,
isStreaming = false,
disabled = false,
placeholder = 'Type a message...',
hideAttachButton = false,
triggerFilePicker = $bindable()
}: Props = $props();
// Input state
let inputValue = $state('');
let textareaElement: HTMLTextAreaElement | null = $state(null);
// Image state (for vision models)
let pendingImages = $state<string[]>([]);
// File attachment state (text/PDF for all models)
let pendingAttachments = $state<FileAttachment[]>([]);
// Drag overlay state
let isDragOver = $state(false);
let dragCounter = 0; // Track enter/leave for nested elements
// Derived state
const hasContent = $derived(
inputValue.trim().length > 0 || pendingImages.length > 0 || pendingAttachments.length > 0
);
const canSend = $derived(hasContent && !disabled && !isStreaming);
const showStopButton = $derived(isStreaming);
// Vision model detection
const isVisionModel = $derived(modelsState.selectedSupportsVision);
// Token estimation for current input
const tokenEstimate = $derived(
estimateMessageTokens(inputValue, pendingImages.length > 0 ? pendingImages : undefined)
);
const showTokenCount = $derived(inputValue.length > 0 || pendingImages.length > 0);
/**
* Auto-resize textarea based on content
*/
function autoResize(): void {
if (!textareaElement) return;
// Reset height to auto to get the correct scrollHeight
textareaElement.style.height = 'auto';
// Calculate new height (max 200px)
const newHeight = Math.min(textareaElement.scrollHeight, 200);
textareaElement.style.height = `${newHeight}px`;
}
/**
* Handle input changes
*/
function handleInput(): void {
autoResize();
}
/**
* Handle keyboard events
*/
function handleKeydown(event: KeyboardEvent): void {
// Enter to send (without Shift)
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
handleSend();
}
}
/**
* Send the message
*/
function handleSend(): void {
if (!canSend) return;
let content = inputValue.trim();
const images = pendingImages.length > 0 ? [...pendingImages] : undefined;
// Prepend file attachments content to the message
if (pendingAttachments.length > 0) {
const attachmentContent = formatAttachmentsForMessage(pendingAttachments);
if (attachmentContent) {
content = attachmentContent + (content ? '\n\n' + content : '');
}
}
// Clear input, images, and attachments
inputValue = '';
pendingImages = [];
pendingAttachments = [];
// Reset textarea height
if (textareaElement) {
textareaElement.style.height = 'auto';
}
onSend?.(content, images);
// Keep focus on input after sending
requestAnimationFrame(() => focusInput());
}
/**
* Stop streaming
*/
function handleStop(): void {
onStop?.();
}
/**
* Handle image changes from FileUpload
*/
function handleImagesChange(images: string[]): void {
pendingImages = images;
}
/**
* Handle attachment changes from FileUpload
*/
function handleAttachmentsChange(attachments: FileAttachment[]): void {
pendingAttachments = attachments;
}
/**
* Focus the textarea
*/
function focusInput(): void {
if (textareaElement && !disabled) {
textareaElement.focus();
}
}
// Focus textarea on mount
$effect(() => {
focusInput();
});
// =========================================================================
// Drag & Drop - Document-level listeners for reliable detection
// =========================================================================
// Set up document-level drag listeners for reliable detection
$effect(() => {
if (disabled) return;
function onDragEnter(event: DragEvent): void {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
dragCounter++;
isDragOver = true;
}
function onDragLeave(event: DragEvent): void {
event.preventDefault();
dragCounter--;
if (dragCounter <= 0) {
dragCounter = 0;
isDragOver = false;
}
}
function onDragOver(event: DragEvent): void {
if (!event.dataTransfer?.types.includes('Files')) return;
event.preventDefault();
if (event.dataTransfer) {
event.dataTransfer.dropEffect = 'copy';
}
}
function onDrop(event: DragEvent): void {
event.preventDefault();
isDragOver = false;
dragCounter = 0;
if (!event.dataTransfer?.files.length) return;
const files = Array.from(event.dataTransfer.files);
processDroppedFiles(files);
}
document.addEventListener('dragenter', onDragEnter);
document.addEventListener('dragleave', onDragLeave);
document.addEventListener('dragover', onDragOver);
document.addEventListener('drop', onDrop);
return () => {
document.removeEventListener('dragenter', onDragEnter);
document.removeEventListener('dragleave', onDragLeave);
document.removeEventListener('dragover', onDragOver);
document.removeEventListener('drop', onDrop);
};
});
/**
* Process dropped files - images go to pendingImages, others to attachments
*/
async function processDroppedFiles(files: File[]): Promise<void> {
const imageFiles: File[] = [];
const otherFiles: File[] = [];
for (const file of files) {
if (isImageMimeType(file.type)) {
imageFiles.push(file);
} else {
otherFiles.push(file);
}
}
// Process images if vision is supported
if (imageFiles.length > 0 && isVisionModel) {
const { processImageForOllama, isValidImageType } = await import('$lib/ollama/image-processor');
const validImages = imageFiles.filter(isValidImageType);
const maxImages = 4;
const remainingSlots = maxImages - pendingImages.length;
const toProcess = validImages.slice(0, remainingSlots);
for (const file of toProcess) {
try {
const processed = await processImageForOllama(file);
pendingImages = [...pendingImages, processed.base64];
} catch (err) {
console.error('Failed to process image:', err);
}
}
}
// Process other files
for (const file of otherFiles) {
const result = await processFile(file);
if (result.success) {
pendingAttachments = [...pendingAttachments, result.attachment];
}
}
}
</script>
<!-- Full-screen drag overlay - shown when dragging files anywhere on the page -->
{#if isDragOver}
<div class="pointer-events-none fixed inset-0 z-[100] flex items-center justify-center bg-[var(--color-bg-primary)]/80 backdrop-blur-sm">
<div class="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-violet-500 bg-theme-secondary p-8 text-violet-600 dark:text-violet-300">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="h-12 w-12">
<path fill-rule="evenodd" d="M10.5 3.75a6 6 0 0 0-5.98 6.496A5.25 5.25 0 0 0 6.75 20.25H18a4.5 4.5 0 0 0 2.206-8.423 3.75 3.75 0 0 0-4.133-4.303A6.001 6.001 0 0 0 10.5 3.75Zm2.03 5.47a.75.75 0 0 0-1.06 0l-3 3a.75.75 0 1 0 1.06 1.06l1.72-1.72v4.19a.75.75 0 0 0 1.5 0v-4.19l1.72 1.72a.75.75 0 1 0 1.06-1.06l-3-3Z" clip-rule="evenodd" />
</svg>
<span class="text-lg font-medium">
{#if isVisionModel}
Drop images or files
{:else}
Drop files here
{/if}
</span>
<span class="text-sm text-theme-muted">
{#if isVisionModel}
Images, text files, and PDFs supported
{:else}
Text files and PDFs supported
{/if}
</span>
</div>
</div>
{/if}
<div class="relative space-y-2">
<!-- File upload area (images for vision models, text/PDFs for all) -->
<FileUpload
images={pendingImages}
onImagesChange={handleImagesChange}
attachments={pendingAttachments}
onAttachmentsChange={handleAttachmentsChange}
supportsVision={isVisionModel}
{disabled}
hideDropZone={true}
hideButton={hideAttachButton}
bind:triggerPicker={triggerFilePicker}
/>
<div
class="flex items-end gap-3 rounded-2xl border border-theme bg-theme-input p-3 backdrop-blur transition-all focus-within:border-theme-subtle"
>
<!-- Attachment indicators -->
{#if pendingImages.length > 0 || pendingAttachments.length > 0}
<div class="flex h-9 items-center gap-1.5">
{#if pendingImages.length > 0}
<span class="flex items-center gap-1 rounded-lg bg-violet-500/20 px-2 py-1 text-xs font-medium text-violet-300" title="Images attached">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0l-2.97 2.97ZM12 7a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z"
clip-rule="evenodd"
/>
</svg>
{pendingImages.length}
</span>
{/if}
{#if pendingAttachments.length > 0}
<span class="flex items-center gap-1 rounded-lg bg-emerald-500/20 px-2 py-1 text-xs font-medium text-emerald-300" title="Files attached">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-3.5 w-3.5"
>
<path
fill-rule="evenodd"
d="M15.621 4.379a3 3 0 0 0-4.242 0l-7 7a3 3 0 0 0 4.241 4.243h.001l.497-.5a.75.75 0 0 1 1.064 1.057l-.498.501-.002.002a4.5 4.5 0 0 1-6.364-6.364l7-7a4.5 4.5 0 0 1 6.368 6.36l-3.455 3.553A2.625 2.625 0 1 1 9.52 9.52l3.45-3.451a.75.75 0 1 1 1.061 1.06l-3.45 3.451a1.125 1.125 0 0 0 1.587 1.595l3.454-3.553a3 3 0 0 0 0-4.242Z"
clip-rule="evenodd"
/>
</svg>
{pendingAttachments.length}
</span>
{/if}
</div>
{/if}
<!-- Textarea - allow typing during streaming, only block sending -->
<textarea
bind:this={textareaElement}
bind:value={inputValue}
oninput={handleInput}
onkeydown={handleKeydown}
{placeholder}
{disabled}
rows="1"
class="max-h-[200px] min-h-[40px] flex-1 resize-none bg-transparent px-1 py-1.5 text-theme-primary placeholder-theme-placeholder focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
aria-label="Message input"
data-chat-input
></textarea>
<!-- Action buttons -->
<div class="flex items-center">
{#if showStopButton}
<!-- Stop button -->
<button
type="button"
onclick={handleStop}
class="flex h-9 w-9 items-center justify-center rounded-xl bg-red-500/20 text-red-400 transition-colors hover:bg-red-500/30 hover:text-red-300 focus:outline-none focus:ring-2 focus:ring-red-500/50"
aria-label="Stop generating"
title="Stop generating"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<rect x="5" y="5" width="10" height="10" rx="1" />
</svg>
</button>
{:else}
<!-- Send button -->
<button
type="button"
onclick={handleSend}
disabled={!canSend}
class="flex h-9 w-9 items-center justify-center rounded-xl transition-colors focus:outline-none focus:ring-2 focus:ring-violet-500/50 {canSend
? 'bg-violet-500/20 text-violet-400 hover:bg-violet-500/30 hover:text-violet-300'
: 'text-theme-muted cursor-not-allowed'}"
aria-label="Send message"
title="Send message"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="h-5 w-5"
>
<path d="M3.105 2.289a.75.75 0 00-.826.95l1.414 4.925A1.5 1.5 0 005.135 9.25h6.115a.75.75 0 010 1.5H5.135a1.5 1.5 0 00-1.442 1.086l-1.414 4.926a.75.75 0 00.826.95 28.896 28.896 0 0015.293-7.154.75.75 0 000-1.115A28.897 28.897 0 003.105 2.289z" />
</svg>
</button>
{/if}
</div>
</div>
<!-- Subtle helper text -->
<p class="text-center text-[11px] text-theme-muted">
<kbd class="rounded bg-theme-secondary px-1 py-0.5 font-mono">Enter</kbd> send
<span class="mx-1.5 opacity-50">·</span>
<kbd class="rounded bg-theme-secondary px-1 py-0.5 font-mono">Shift+Enter</kbd> new line
<span class="mx-1.5 opacity-50">·</span>
{#if isVisionModel}
<span class="text-violet-500 dark:text-violet-500/70">images</span>
<span class="mx-1 opacity-50">+</span>
{/if}
<span class="text-theme-muted">files supported</span>
{#if showTokenCount}
<span class="mx-1.5 opacity-50">·</span>
<span class="text-theme-muted" title="{tokenEstimate.textTokens} text + {tokenEstimate.imageTokens} image tokens">
~{formatTokenCount(tokenEstimate.totalTokens)} tokens
</span>
{/if}
</p>
</div>