← 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>