← back to Exo
dashboard/src/lib/components/ChatForm.svelte
598 lines
<script lang="ts">
import {
isLoading,
editingImage,
clearEditingImage,
selectedChatModel,
ttftMs,
tps,
totalTokens,
thinkingEnabled as thinkingEnabledStore,
setConversationThinking,
stopGeneration,
} from "$lib/stores/app.svelte";
import ChatAttachments from "./ChatAttachments.svelte";
import ImageParamsPanel from "./ImageParamsPanel.svelte";
import type { ChatUploadedFile } from "$lib/types/files";
import { processUploadedFiles, getAcceptString } from "$lib/types/files";
interface Props {
class?: string;
placeholder?: string;
showHelperText?: boolean;
autofocus?: boolean;
showModelSelector?: boolean;
modelTasks?: Record<string, string[]>;
modelCapabilities?: Record<string, string[]>;
onSend?: () => void;
onAutoSend: (
content: string,
files?: {
id: string;
name: string;
type: string;
textContent?: string;
preview?: string;
}[],
) => void;
onOpenModelPicker?: () => void;
modelDisplayOverride?: string;
}
let {
class: className = "",
placeholder = "Ask anything",
showHelperText = false,
autofocus = true,
showModelSelector = false,
modelTasks = {},
modelCapabilities = {},
onSend,
onAutoSend,
onOpenModelPicker,
modelDisplayOverride,
}: Props = $props();
let message = $state("");
let textareaRef: HTMLTextAreaElement | undefined = $state();
let fileInputRef: HTMLInputElement | undefined = $state();
let uploadedFiles = $state<ChatUploadedFile[]>([]);
let isDragOver = $state(false);
const thinkingEnabled = $derived(thinkingEnabledStore());
let loading = $derived(isLoading());
const currentModel = $derived(selectedChatModel());
const currentTtft = $derived(ttftMs());
const currentTps = $derived(tps());
const currentTokens = $derived(totalTokens());
const currentEditingImage = $derived(editingImage());
const isEditMode = $derived(currentEditingImage !== null);
// Accept all supported file types
const acceptString = getAcceptString(["image", "text", "pdf"]);
function modelSupportsImageGeneration(modelId: string): boolean {
const tasks = modelTasks[modelId] || [];
return tasks.includes("TextToImage") || tasks.includes("ImageToImage");
}
function modelSupportsTextToImage(modelId: string): boolean {
const tasks = modelTasks[modelId] || [];
return tasks.includes("TextToImage");
}
function modelSupportsOnlyImageEditing(modelId: string): boolean {
const tasks = modelTasks[modelId] || [];
return tasks.includes("ImageToImage") && !tasks.includes("TextToImage");
}
function modelSupportsImageEditing(modelId: string): boolean {
const tasks = modelTasks[modelId] || [];
return tasks.includes("ImageToImage");
}
const isImageModel = $derived(() => {
if (!currentModel) return false;
return (
modelSupportsTextToImage(currentModel) ||
modelSupportsImageEditing(currentModel)
);
});
const modelSupportsThinking = $derived(() => {
if (!currentModel) return false;
const caps = modelCapabilities[currentModel] || [];
return caps.includes("thinking_toggle") && caps.includes("text");
});
const isEditOnlyWithoutImage = $derived(
currentModel !== null &&
modelSupportsOnlyImageEditing(currentModel) &&
!isEditMode &&
uploadedFiles.length === 0,
);
// Show edit mode when: explicit edit mode OR (model supports ImageToImage AND files attached)
const shouldShowEditMode = $derived(
isEditMode ||
(currentModel &&
modelSupportsImageEditing(currentModel) &&
uploadedFiles.length > 0),
);
// Short label for the currently selected model
const currentModelLabel = $derived(
currentModel
? currentModel.split("/").pop() || currentModel
: modelDisplayOverride
? modelDisplayOverride.split("/").pop() || modelDisplayOverride
: "",
);
async function handleFiles(files: File[]) {
if (files.length === 0) return;
const processed = await processUploadedFiles(files);
uploadedFiles = [...uploadedFiles, ...processed];
}
function handleFileInputChange(event: Event) {
const input = event.target as HTMLInputElement;
if (input.files && input.files.length > 0) {
handleFiles(Array.from(input.files));
input.value = ""; // Reset for next selection
}
}
function handleFileRemove(fileId: string) {
uploadedFiles = uploadedFiles.filter((f) => f.id !== 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();
handleFiles(files);
return;
}
// Handle long text paste as file
const text = event.clipboardData.getData("text/plain");
if (text.length > 2500) {
event.preventDefault();
const textFile = new File([text], "pasted-text.txt", {
type: "text/plain",
});
handleFiles([textFile]);
}
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragOver = true;
}
function handleDragLeave(event: DragEvent) {
event.preventDefault();
isDragOver = false;
}
function handleDrop(event: DragEvent) {
event.preventDefault();
isDragOver = false;
if (event.dataTransfer?.files) {
handleFiles(Array.from(event.dataTransfer.files));
}
}
function handleKeydown(event: KeyboardEvent) {
// Prevent form submission during IME composition (e.g., Chinese, Japanese, Korean input)
if (event.isComposing || event.keyCode === 229) {
return;
}
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleSubmit();
}
}
function handleSubmit() {
if ((!message.trim() && uploadedFiles.length === 0) || loading) return;
if (isEditOnlyWithoutImage) return;
const content = message.trim();
const files = [...uploadedFiles];
message = "";
uploadedFiles = [];
resetTextareaHeight();
// Parent controls all send logic (including image routing,
// launching non-running models before sending, etc.)
onAutoSend(content, files);
onSend?.();
setTimeout(() => textareaRef?.focus(), 10);
}
function handleInput() {
if (!textareaRef) return;
textareaRef.style.height = "auto";
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
}
function resetTextareaHeight() {
if (textareaRef) {
textareaRef.style.height = "auto";
}
}
function openFilePicker() {
fileInputRef?.click();
}
// Track previous loading state to detect when loading completes
let wasLoading = $state(false);
$effect(() => {
if (autofocus && textareaRef) {
setTimeout(() => textareaRef?.focus(), 10);
}
});
// Refocus after loading completes (AI response finished)
$effect(() => {
if (wasLoading && !loading && textareaRef) {
setTimeout(() => textareaRef?.focus(), 50);
}
wasLoading = loading;
});
const canSend = $derived(
message.trim().length > 0 || uploadedFiles.length > 0,
);
</script>
<!-- Hidden file input -->
<input
bind:this={fileInputRef}
type="file"
accept={acceptString}
multiple
class="hidden"
onchange={handleFileInputChange}
/>
<form
onsubmit={(e) => {
e.preventDefault();
handleSubmit();
}}
class="w-full {className}"
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
>
<div
class="relative command-panel rounded overflow-hidden transition-all duration-200 {isDragOver
? 'ring-2 ring-exo-yellow ring-opacity-50'
: ''}"
>
<!-- Top accent line -->
<div
class="absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/50 to-transparent"
></div>
<!-- Drag overlay -->
{#if isDragOver}
<div
class="absolute inset-0 bg-exo-dark-gray/80 z-10 flex items-center justify-center"
>
<div class="text-exo-yellow text-sm font-mono tracking-wider uppercase">
DROP FILES HERE
</div>
</div>
{/if}
<!-- Edit mode banner -->
{#if isEditMode && currentEditingImage}
<div
class="flex items-center gap-3 px-3 py-2 bg-exo-yellow/10 border-b border-exo-yellow/30"
>
<img
src={currentEditingImage.imageDataUrl}
alt="Source for editing"
class="w-10 h-10 object-cover rounded border border-exo-yellow/30"
/>
<div class="flex-1">
<span
class="text-xs font-mono tracking-wider uppercase text-exo-yellow"
>EDITING IMAGE</span
>
</div>
<button
type="button"
onclick={() => clearEditingImage()}
class="px-2 py-1 text-xs font-mono tracking-wider uppercase bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50 rounded hover:bg-exo-medium-gray/50 hover:text-exo-yellow transition-colors cursor-pointer"
>
CANCEL
</button>
</div>
{/if}
<!-- Model selector (when enabled) -->
{#if showModelSelector}
<div
class="flex items-center justify-between gap-2 px-3 py-2 border-b border-exo-medium-gray/30"
>
<div class="flex items-center gap-2 flex-1">
<span
class="text-xs text-exo-light-gray uppercase tracking-wider flex-shrink-0"
>MODEL:</span
>
<!-- Model button — opens the full model picker -->
<div class="relative flex-1 max-w-xs">
<button
type="button"
onclick={() => onOpenModelPicker?.()}
class="w-full bg-exo-medium-gray/50 border border-exo-yellow/30 rounded pl-3 pr-8 py-1.5 text-xs font-mono text-left tracking-wide cursor-pointer transition-all duration-200 hover:border-exo-yellow/50 focus:outline-none focus:border-exo-yellow/70"
>
{#if currentModelLabel}
<span class="text-exo-yellow truncate">{currentModelLabel}</span
>
{:else}
<span class="text-exo-light-gray/50">— SELECT MODEL —</span>
{/if}
</button>
<div
class="absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none"
>
<svg
class="w-3 h-3 text-exo-yellow/60"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M19 9l-7 7-7-7"
/>
</svg>
</div>
</div>
</div>
<!-- Thinking toggle -->
{#if modelSupportsThinking()}
<button
type="button"
onclick={() => setConversationThinking(!thinkingEnabled)}
class="flex items-center gap-1.5 px-2 py-1 rounded text-xs font-mono tracking-wide transition-all duration-200 flex-shrink-0 cursor-pointer border {thinkingEnabled
? 'bg-exo-yellow/15 border-exo-yellow/40 text-exo-yellow'
: 'bg-exo-medium-gray/30 border-exo-medium-gray/50 text-exo-light-gray/60 hover:text-exo-light-gray'}"
title={thinkingEnabled
? "Thinking enabled — click to disable"
: "Thinking disabled — click to enable"}
>
<svg
class="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
>
<path
d="M12 2a7 7 0 0 0-7 7c0 2.38 1.19 4.47 3 5.74V17a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1v-2.26c1.81-1.27 3-3.36 3-5.74a7 7 0 0 0-7-7zM9 20h6M10 22h4"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
<span>{thinkingEnabled ? "THINK" : "NO THINK"}</span>
</button>
{/if}
<!-- Performance stats -->
{#if currentTtft !== null || currentTps !== null}
<div class="flex items-center gap-4 text-xs font-mono flex-shrink-0">
{#if currentTtft !== null}
<span class="text-exo-light-gray">
<span class="text-white/70">TTFT</span>
<span class="text-exo-yellow">{currentTtft.toFixed(1)}ms</span>
</span>
{/if}
{#if currentTps !== null}
<span class="text-exo-light-gray">
<span class="text-white/70">TPS</span>
<span class="text-exo-yellow">{currentTps.toFixed(1)}</span>
<span class="text-white/60">tok/s</span>
<span class="text-white/50"
>({(1000 / currentTps).toFixed(1)} ms/tok)</span
>
</span>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Image params panel (shown for image models or edit mode) -->
{#if showModelSelector && (isImageModel() || isEditMode)}
<ImageParamsPanel {isEditMode} />
{/if}
<!-- Attached files preview -->
{#if uploadedFiles.length > 0}
<div class="px-3 pt-3">
<ChatAttachments files={uploadedFiles} onRemove={handleFileRemove} />
</div>
{/if}
<!-- Input area -->
<div class="flex items-start gap-2 sm:gap-3 py-3 px-3 sm:px-4">
<!-- Attach file button -->
<button
type="button"
onclick={openFilePicker}
disabled={loading}
class="flex items-center justify-center w-7 h-7 rounded text-exo-light-gray hover:text-exo-yellow transition-all disabled:opacity-50 disabled:cursor-not-allowed flex-shrink-0 cursor-pointer"
title="Attach file"
>
<svg
class="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13"
/>
</svg>
</button>
<!-- Terminal prompt -->
<span class="text-exo-yellow text-sm font-bold flex-shrink-0 leading-7"
>▶</span
>
<textarea
bind:this={textareaRef}
bind:value={message}
onkeydown={handleKeydown}
oninput={handleInput}
onpaste={handlePaste}
placeholder={isEditOnlyWithoutImage
? "Attach an image to edit..."
: isEditMode
? "Describe how to edit this image..."
: isImageModel()
? "Describe the image you want to generate..."
: placeholder}
rows={1}
class="flex-1 resize-none bg-transparent text-foreground placeholder:text-exo-light-gray/60 placeholder:text-sm placeholder:tracking-[0.15em] placeholder:leading-7 focus:outline-none focus:ring-0 focus:border-none text-sm leading-7 font-mono"
style="min-height: 28px; max-height: 150px;"
></textarea>
{#if loading}
<button
type="button"
onclick={() => stopGeneration()}
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] font-medium transition-all duration-200 whitespace-nowrap bg-exo-medium-gray/70 text-exo-light-gray hover:bg-exo-medium-gray hover:text-white"
aria-label="Stop generation"
>
<span class="inline-flex items-center gap-1 sm:gap-2">
<svg
class="w-3 h-3 sm:w-3.5 sm:h-3.5"
fill="currentColor"
viewBox="0 0 24 24"
>
<rect x="6" y="6" width="12" height="12" rx="1" />
</svg>
<span class="hidden sm:inline">Cancel</span>
</span>
</button>
{:else}
<button
type="submit"
disabled={!canSend || isEditOnlyWithoutImage}
class="px-2.5 sm:px-4 py-1.5 sm:py-2 rounded text-xs sm:text-xs tracking-[0.1em] sm:tracking-[0.15em] uppercase font-medium transition-all duration-200 whitespace-nowrap
{!canSend || isEditOnlyWithoutImage
? 'bg-exo-medium-gray/50 text-exo-light-gray cursor-not-allowed'
: 'bg-exo-yellow text-exo-black hover:bg-exo-yellow-darker hover:shadow-[0_0_20px_rgba(255,215,0,0.3)]'}"
aria-label={shouldShowEditMode
? "Edit image"
: isImageModel()
? "Generate image"
: "Send message"}
>
{#if shouldShowEditMode}
<span class="inline-flex items-center gap-1.5">
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span>EDIT</span>
</span>
{:else if isEditOnlyWithoutImage}
<span class="inline-flex items-center gap-1.5">
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
/>
</svg>
<span>EDIT</span>
</span>
{:else if isImageModel()}
<span class="inline-flex items-center gap-1.5">
<svg
class="w-3.5 h-3.5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<rect x="3" y="3" width="18" height="18" rx="2" ry="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
<span>GENERATE</span>
</span>
{:else}
SEND
{/if}
</button>
{/if}
</div>
<!-- Bottom accent line -->
<div
class="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-exo-yellow/30 to-transparent"
></div>
</div>
{#if showHelperText}
<p
class="mt-2 sm:mt-3 text-center text-xs sm:text-xs text-exo-light-gray tracking-[0.1em] sm:tracking-[0.15em] uppercase"
>
<kbd
class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
>ENTER</kbd
>
<span class="mx-0.5 sm:mx-1">TO SEND</span>
<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
<kbd
class="px-1 sm:px-1.5 py-0.5 rounded bg-exo-medium-gray/30 text-exo-light-gray border border-exo-medium-gray/50"
>SHIFT+ENTER</kbd
>
<span class="mx-0.5 sm:mx-1">NEW LINE</span>
<span class="text-exo-medium-gray mx-1 sm:mx-2">|</span>
<span class="text-exo-light-gray">DRAG & DROP OR PASTE FILES</span>
</p>
{/if}
</form>