aegida-console / components / chat / chat-composer.tsx
chat-composer.tsx
Raw
"use client";

import { ArrowUp, Paperclip, Square, X } from "lucide-react";
import { useRef, useState, type ChangeEvent, type KeyboardEvent } from "react";

import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { MAX_CHAT_MESSAGE_CHARACTERS } from "@/lib/chat/request-limits";
import type { Attachment } from "@/lib/chat/types";
import { authFetch } from "@/lib/auth/client";

type ChatComposerProps = {
  isGenerating: boolean;
  onSend: (content: string, attachments?: Attachment[]) => Promise<void> | void;
  onStop: () => void;
  quotaExhausted: boolean;
};

export function ChatComposer({
  isGenerating,
  onSend,
  onStop,
  quotaExhausted,
}: ChatComposerProps) {
  const [content, setContent] = useState("");
  const [attachments, setAttachments] = useState<Attachment[]>([]);
  const [isUploading, setIsUploading] = useState(false);
  const textareaRef = useRef<HTMLTextAreaElement>(null);
  const fileInputRef = useRef<HTMLInputElement>(null);
  const messageLength = content.trim().length;
  const isTooLong = messageLength > MAX_CHAT_MESSAGE_CHARACTERS;
  const canSend = (messageLength > 0 || attachments.length > 0) && !isTooLong && !isGenerating && !isUploading && !quotaExhausted;

  function resize(event: ChangeEvent<HTMLTextAreaElement>) {
    const textarea = event.currentTarget;
    textarea.style.height = "auto";
    textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
  }

  function submit() {
    const message = content.trim();
    if ((!message && attachments.length === 0) || message.length > MAX_CHAT_MESSAGE_CHARACTERS || isGenerating || isUploading || quotaExhausted) {
      return;
    }
    setContent("");
    const submittedAttachments = attachments;
    setAttachments([]);
    if (textareaRef.current) textareaRef.current.style.height = "auto";
    void (submittedAttachments.length
      ? onSend(message, submittedAttachments)
      : onSend(message));
  }

  async function uploadFiles(files: FileList | null) {
    if (!files?.length || isUploading) return;
    setIsUploading(true);
    try {
      for (const file of Array.from(files)) {
        const form = new FormData();
        form.set("file", file);
        const response = await authFetch("/api/attachments", { method: "POST", body: form });
        if (!response.ok) throw new Error("upload failed");
        const body = (await response.json()) as { attachment: Attachment };
        setAttachments((current) => [...current, body.attachment]);
      }
    } finally {
      setIsUploading(false);
      if (fileInputRef.current) fileInputRef.current.value = "";
    }
  }

  function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
    if (event.key === "Enter" && !event.shiftKey) {
      event.preventDefault();
      submit();
    }
  }

  return (
    <div className="shrink-0 bg-background px-3 pb-3 md:px-5 md:pb-4">
      <div className="chat-readable mx-auto">
        <div className="chat-composer-surface flex items-end gap-2 rounded-2xl border bg-card p-2 focus-within:border-ring focus-within:ring-2 focus-within:ring-ring/30">
          <input accept="image/png,image/jpeg,image/webp,application/pdf,text/plain,text/markdown,.md,.docx" aria-label="Прикрепить файлы" className="sr-only" multiple onChange={(event) => void uploadFiles(event.target.files)} ref={fileInputRef} type="file" />
          <Button aria-label="Прикрепить файлы" className="mb-1 rounded-full" disabled={isGenerating || isUploading} onClick={() => fileInputRef.current?.click()} size="icon" type="button"><Paperclip aria-hidden="true" /></Button>
          <Textarea
            aria-describedby={isTooLong ? "chat-message-limit" : undefined}
            aria-invalid={isTooLong}
            aria-label="Сообщение"
            className="max-h-[200px] min-h-11 flex-1 border-0 px-2.5 py-2.5 shadow-none focus-visible:border-transparent focus-visible:ring-0"
            disabled={isGenerating}
            onChange={(event) => {
              setContent(event.target.value);
              resize(event);
            }}
            onKeyDown={handleKeyDown}
            placeholder="Напишите сообщение"
            ref={textareaRef}
            rows={1}
            value={content}
          />
          {isGenerating ? (
            <Button
              aria-label="Остановить генерацию"
              className="mb-1 rounded-full"
              onClick={onStop}
              size="icon"
              type="button"
            >
              <Square aria-hidden="true" className="size-3 fill-current" />
            </Button>
          ) : (
            <Button
              aria-label="Отправить сообщение"
              className="mb-1 rounded-full"
              disabled={!canSend}
              onClick={submit}
              size="icon"
              type="button"
            >
              <ArrowUp aria-hidden="true" />
            </Button>
          )}
        </div>
        {attachments.length ? <div className="mt-2 flex flex-wrap gap-2">{attachments.map((attachment) => <span className="flex items-center gap-1 rounded-lg border px-2 py-1 text-xs" key={attachment.id}>{attachment.name}<button aria-label={`Удалить ${attachment.name}`} onClick={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))} type="button"><X className="size-3" /></button></span>)}</div> : null}
        {isUploading ? <p className="mt-2 text-xs text-muted-foreground">Загружаю вложение</p> : null}
        {quotaExhausted ? (
          <p className="mt-2 text-xs text-destructive" role="status">
            Лимит токенов исчерпан. Отправка недоступна.
          </p>
        ) : null}
        {isTooLong ? (
          <p
            className="mt-1.5 px-2 text-xs text-destructive"
            id="chat-message-limit"
            role="alert"
          >
            Сообщение не должно превышать 32 000 символов.
          </p>
        ) : null}
        <p className="mt-2 text-center text-[11px] leading-4 text-muted-foreground">
          ИИ может допускать ошибки. Проверяйте важную информацию.
        </p>
      </div>
    </div>
  );
}