SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
14 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
19.9 KB · 436 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { ArrowUp, Camera, ChevronDown, ChevronUp, ClipboardPaste, FileText, ImagePlus, Library, Mic, MicOff, Plus, Square } from "lucide-react";4import type { PolyModel } from "@/lib/client/types";5import { Button } from "@/components/ui/button";6import { Tooltip } from "@/components/ui/tooltip";7import { Kbd } from "@/components/ui/misc";8import { toast } from "@/components/ui/toast";9import { ActionSheet, type ActionSheetItem } from "@/components/ui/sheet";10import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";11import { FileLibraryPicker, type PickedAttachment } from "@/components/library/file-library-picker";12import { useIsCoarsePointer, useIsMobile } from "@/lib/client/hooks";13import { AttachmentChip } from "./message";14import { useSpeechDictation } from "./use-speech";15import { cn } from "@/lib/utils";1617export interface PendingAttachment {18  id: string;19  kind: string;20  name: string;21  mimeType: string;22  sizeBytes: number;23  width?: number | null;24  height?: number | null;25}2627/** Extra entries for the `+` menu (chat adds tools, web search, structured output, system prompt, temporary chat). */28export interface ComposerAction {29  key: string;30  label: React.ReactNode;31  icon?: React.ReactNode;32  hint?: React.ReactNode;33  onSelect: () => void;34  selected?: boolean;35  disabled?: boolean;36  destructive?: boolean;37}3839export interface ComposerHandle {40  focus(): void;41  /** Open the native file picker (`kind` narrows the accepted types). */42  openFilePicker(kind?: "image" | "document" | "camera"): void;43  /** Insert text at the caret (or append) and focus. */44  insert(text: string): void;45}4647interface Props {48  model?: PolyModel;49  disabled?: boolean;50  busy?: boolean;51  enterToSend?: boolean;52  placeholder?: string;53  attachments: PendingAttachment[];54  onAttachmentsChange: (a: PendingAttachment[]) => void;55  onSend: (text: string) => void;56  onStop: () => void;57  leftSlot?: React.ReactNode;58  rightSlot?: React.ReactNode;59  /** Row rendered above the input box (model pill, context indicator…). */60  topSlot?: React.ReactNode;61  /** Row rendered below the input box (hints, banners). */62  bottomSlot?: React.ReactNode;63  autoFocus?: boolean;64  value?: string;65  onValueChange?: (v: string) => void;66  /** Extra `+` menu entries, appended after the attachment actions. */67  extraActions?: (ComposerAction | "separator")[];68  /** Hide the microphone even when the browser supports dictation. */69  voice?: boolean;70  /** Maximum visible lines before the textarea scrolls (default 6). */71  maxLines?: number;72  /** Allow uploads even without a model (e.g. AUTO routing). */73  allowAttachWithoutModel?: boolean;74  /** Which library the "From library" picker should target. */75  projectId?: string | null;76}7778const LINE_PX = 24;79const PAD_Y = 20; // pt-2.5 + pb-2.58081const TEXT_ACCEPT = ".txt,.md,.csv,.json,.js,.ts,.tsx,.jsx,.py,.go,.rs,.java,.kt,.swift,.rb,.php,.c,.cpp,.h,.cs,.sh,.yaml,.yml,.toml,.sql,.html,.css,.xml";82const IMAGE_ACCEPT = "image/png,image/jpeg,image/webp,image/gif";8384export const Composer = React.forwardRef<ComposerHandle, Props>(function Composer(85  { model, disabled, busy, enterToSend = true, placeholder, attachments, onAttachmentsChange, onSend, onStop, leftSlot, rightSlot, topSlot, bottomSlot, autoFocus, value, onValueChange, extraActions, voice = true, maxLines = 6, allowAttachWithoutModel, projectId },86  ref,87) {88  const [inner, setInner] = React.useState("");89  const text = value ?? inner;90  const setText = onValueChange ?? setInner;91  const textRef = React.useRef(text);92  React.useEffect(() => {93    textRef.current = text;94  }, [text]);95  const taRef = React.useRef<HTMLTextAreaElement>(null);96  const fileRef = React.useRef<HTMLInputElement>(null);97  const cameraRef = React.useRef<HTMLInputElement>(null);98  const [fileAccept, setFileAccept] = React.useState<string>("");99  const [uploading, setUploading] = React.useState(0);100  const [dragging, setDragging] = React.useState(false);101  const [collapsed, setCollapsed] = React.useState(false);102  const [multiline, setMultiline] = React.useState(false);103  const [menuOpen, setMenuOpen] = React.useState(false);104  const [libraryOpen, setLibraryOpen] = React.useState(false);105  const isMobile = useIsMobile();106  const coarse = useIsCoarsePointer();107108  const canAttach = Boolean(model) || Boolean(allowAttachWithoutModel);109  const canImages = model ? Boolean(model.capabilities.vision) : Boolean(allowAttachWithoutModel);110  const canPdf = model ? Boolean(model.capabilities.files) : Boolean(allowAttachWithoutModel);111112  // --- auto-grow 1 → maxLines, then scroll -------------------------------------------------------113  React.useLayoutEffect(() => {114    const el = taRef.current;115    if (!el) return;116    el.style.height = "0px";117    const natural = el.scrollHeight;118    const lines = Math.max(1, Math.round((natural - PAD_Y) / LINE_PX));119    const maxH = maxLines * LINE_PX + PAD_Y;120    const target = collapsed ? LINE_PX + PAD_Y : Math.min(maxH, Math.max(LINE_PX + PAD_Y, natural));121    el.style.height = `${target}px`;122    el.style.overflowY = natural > target ? "auto" : "hidden";123    setMultiline(lines > 1);124    if (lines <= 1 && collapsed) setCollapsed(false);125  }, [text, maxLines, collapsed]);126127  React.useEffect(() => {128    if (autoFocus && !coarse) taRef.current?.focus();129  }, [autoFocus, coarse]);130131  // --- imperative handle ----------------------------------------------------------------------------132  const insertAtCaret = React.useCallback(133    (snippet: string) => {134      const el = taRef.current;135      const cur = textRef.current;136      if (!el) {137        setText(cur ? `${cur}\n${snippet}` : snippet);138        return;139      }140      const start = el.selectionStart ?? cur.length;141      const end = el.selectionEnd ?? cur.length;142      const before = cur.slice(0, start);143      const after = cur.slice(end);144      const next = `${before}${before && !/\s$/.test(before) ? "\n" : ""}${snippet}${after}`;145      setText(next);146      requestAnimationFrame(() => {147        el.focus();148        const pos = next.length - after.length;149        el.setSelectionRange(pos, pos);150      });151    },152    [setText],153  );154155  const openFilePicker = React.useCallback(156    (kind?: "image" | "document" | "camera") => {157      if (kind === "camera") {158        cameraRef.current?.click();159        return;160      }161      const accept = kind === "image" ? IMAGE_ACCEPT : kind === "document" ? [canPdf ? "application/pdf" : "", TEXT_ACCEPT].filter(Boolean).join(",") : [canImages ? IMAGE_ACCEPT : "", canPdf ? "application/pdf" : "", TEXT_ACCEPT].filter(Boolean).join(",");162      setFileAccept(accept);163      // Let React commit the new `accept` before opening the dialog.164      requestAnimationFrame(() => fileRef.current?.click());165    },166    [canImages, canPdf],167  );168169  React.useImperativeHandle(ref, () => ({ focus: () => taRef.current?.focus(), openFilePicker, insert: insertAtCaret }), [openFilePicker, insertAtCaret]);170171  // --- voice dictation ------------------------------------------------------------------------------172  const baseRef = React.useRef("");173  const speech = useSpeechDictation((final, interim) => {174    const base = baseRef.current;175    const joiner = base && !/\s$/.test(base) ? " " : "";176    setText(`${base}${joiner}${final}${final && interim ? " " : ""}${interim}`);177  });178  const toggleVoice = () => {179    if (!speech.listening) baseRef.current = textRef.current;180    speech.toggle();181  };182  React.useEffect(() => {183    if (speech.error) toast.warning("Dictation unavailable", speech.error);184  }, [speech.error]);185186  // --- submit ---------------------------------------------------------------------------------------187  const submit = () => {188    if (busy) return;189    const t = text.trim();190    if (!t && attachments.length === 0) return;191    if (speech.listening) speech.stop();192    onSend(t);193    setText("");194    setCollapsed(false);195  };196197  // --- uploads --------------------------------------------------------------------------------------198  const upload = async (files: FileList | File[]) => {199    const list = Array.from(files).slice(0, 10 - attachments.length);200    if (!list.length) return;201    let current = attachments;202    for (const f of list) {203      const isImage = f.type.startsWith("image/");204      if (isImage && !canImages) {205        toast.warning("This model has no vision", `${model?.displayName ?? "The selected model"} cannot read images. Pick a vision-capable model.`);206        continue;207      }208      if (f.type === "application/pdf" && !canPdf) {209        toast.warning("PDF not supported by this model", "Pick a model with file input (e.g. Claude, GPT-5.x, Gemini).");210        continue;211      }212      if (f.size > 10 * 1024 * 1024) {213        toast.error("File too large", "Max 10 MB per file.");214        continue;215      }216      setUploading((n) => n + 1);217      try {218        const fd = new FormData();219        fd.append("file", f, f.name || (isImage ? "photo.jpg" : "file"));220        const res = await fetch("/api/attachments", { method: "POST", body: fd });221        const data = await res.json();222        if (!res.ok) throw new Error(data?.error?.message ?? "Upload failed");223        current = [...current, data.attachment];224        onAttachmentsChange(current);225      } catch (e) {226        toast.error("Upload failed", (e as Error).message);227      } finally {228        setUploading((n) => n - 1);229      }230    }231  };232233  const onPaste = (e: React.ClipboardEvent) => {234    const items = Array.from(e.clipboardData.items).filter((i) => i.kind === "file");235    if (!items.length) return;236    const files = items.map((i) => i.getAsFile()).filter((f): f is File => Boolean(f));237    if (files.length) {238      e.preventDefault();239      void upload(files);240    }241  };242243  const pasteFromClipboard = async () => {244    try {245      const nav = navigator as Navigator & { clipboard: Clipboard & { read?: () => Promise<ClipboardItem[]> } };246      if (nav.clipboard.read) {247        const items = await nav.clipboard.read();248        const files: File[] = [];249        let textFound = "";250        for (const item of items) {251          const imgType = item.types.find((t) => t.startsWith("image/"));252          if (imgType) {253            const blob = await item.getType(imgType);254            files.push(new File([blob], `pasted.${imgType.split("/")[1] ?? "png"}`, { type: imgType }));255          } else if (item.types.includes("text/plain")) {256            textFound += await (await item.getType("text/plain")).text();257          }258        }259        if (files.length) await upload(files);260        if (textFound) insertAtCaret(textFound);261        if (!files.length && !textFound) toast.info("Clipboard is empty");262        return;263      }264      const t = await navigator.clipboard.readText();265      if (t) insertAtCaret(t);266      else toast.info("Clipboard is empty");267    } catch {268      toast.warning("Clipboard access denied", "Use ⌘V / Ctrl+V inside the message box instead.");269    }270  };271272  // --- `+` menu -------------------------------------------------------------------------------------273  const items: (ComposerAction | "separator")[] = [274    ...(canAttach275      ? ([276          ...(canImages ? [{ key: "image", label: "Upload image", icon: <ImagePlus />, onSelect: () => openFilePicker("image") }] : []),277          { key: "document", label: "Upload document", icon: <FileText />, hint: canPdf ? "PDF, text, code" : "Text, code", onSelect: () => openFilePicker("document") },278          ...(canImages && coarse ? [{ key: "camera", label: "Camera", icon: <Camera />, onSelect: () => openFilePicker("camera") }] : []),279          { key: "paste", label: "Paste content", icon: <ClipboardPaste />, onSelect: () => void pasteFromClipboard() },280          { key: "library", label: "From library", icon: <Library />, onSelect: () => setLibraryOpen(true) },281        ] as ComposerAction[])282      : []),283    ...(extraActions?.length ? (canAttach ? (["separator", ...extraActions] as (ComposerAction | "separator")[]) : extraActions) : []),284  ];285  const activeCount = (extraActions ?? []).filter((i) => i !== "separator" && i.selected).length;286287  const hasContent = Boolean(text.trim()) || attachments.length > 0;288  const showVoice = voice && speech.supported && !disabled;289290  return (291    <div className="w-full">292      {topSlot}293      <div294        className={cn("relative rounded-[22px] border bg-bg-elevated shadow-sm transition-[border-color,box-shadow]", dragging ? "border-accent shadow-glow" : "border-border focus-within:border-border-strong focus-within:shadow-md", speech.listening && "border-accent/60")}295        onDragOver={(e) => {296          if (!canAttach) return;297          e.preventDefault();298          setDragging(true);299        }}300        onDragLeave={() => setDragging(false)}301        onDrop={(e) => {302          e.preventDefault();303          setDragging(false);304          if (canAttach && e.dataTransfer.files.length) void upload(e.dataTransfer.files);305        }}306      >307        {attachments.length || uploading ? (308          <div className="flex flex-wrap gap-1.5 px-3 pt-3">309            {attachments.map((a) => (310              <AttachmentChip key={a.id} a={a} onRemove={() => onAttachmentsChange(attachments.filter((x) => x.id !== a.id))} />311            ))}312            {uploading ? <div className="inline-flex h-9 items-center gap-2 rounded-lg border border-dashed border-border px-3 text-[12px] text-fg-muted">Uploading…</div> : null}313          </div>314        ) : null}315316        <div className="flex items-end gap-1 px-1.5 py-1.5">317          {/* + menu */}318          <input ref={fileRef} type="file" multiple hidden accept={fileAccept || undefined} onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />319          <input ref={cameraRef} type="file" hidden accept="image/*" capture="environment" onChange={(e) => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />320          {/* On phones the caller's extra controls move to a toolbar row below so the text field keeps its width. */}321          {!isMobile ? leftSlot : null}322          <PlusMenu items={items} open={menuOpen} onOpenChange={setMenuOpen} disabled={disabled && !extraActions?.length} activeCount={activeCount} isMobile={isMobile} />323324          {/* textarea */}325          <div className="relative min-w-0 flex-1">326            <textarea327              ref={taRef}328              value={text}329              onChange={(e) => setText(e.target.value)}330              onPaste={onPaste}331              onKeyDown={(e) => {332                if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing && (enterToSend || e.metaKey || e.ctrlKey)) {333                  e.preventDefault();334                  submit();335                } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {336                  e.preventDefault();337                  submit();338                } else if (e.key === "Escape" && busy) {339                  onStop();340                }341              }}342              placeholder={placeholder ?? (speech.listening ? "Listening…" : "Ask anything…")}343              disabled={disabled}344              rows={1}345              className={cn("block w-full resize-none bg-transparent px-2 pb-2.5 pt-2.5 text-[16px] leading-6 outline-none placeholder:text-fg-subtle disabled:opacity-60 scrollbar-thin sm:text-[15px]", multiline && "pr-8")}346              aria-label="Message"347              enterKeyHint={enterToSend ? "send" : "enter"}348              autoCapitalize="sentences"349            />350            {multiline ? (351              <button type="button" onClick={() => setCollapsed((c) => !c)} className="tap absolute right-0 top-1 rounded-md p-1 text-fg-subtle hover:bg-bg-muted hover:text-fg" aria-label={collapsed ? "Expand message" : "Collapse message"}>352                {collapsed ? <ChevronUp className="size-4" /> : <ChevronDown className="size-4" />}353              </button>354            ) : null}355          </div>356357          {rightSlot}358          {/* voice */}359          {showVoice ? (360            <Tooltip content={speech.listening ? "Stop dictation" : "Dictate"}>361              <Button type="button" variant="ghost" size="icon" className={cn("tap size-9 shrink-0 rounded-full", speech.listening && "bg-accent-soft text-accent animate-pulse-soft")} onClick={toggleVoice} aria-label={speech.listening ? "Stop dictation" : "Start dictation"} aria-pressed={speech.listening}>362                {speech.listening ? <MicOff /> : <Mic />}363              </Button>364            </Tooltip>365          ) : null}366          {/* send / stop */}367          {busy ? (368            <Tooltip content="Stop generation (Esc)">369              <Button type="button" size="icon" variant="secondary" className="tap size-9 shrink-0 rounded-full" onClick={onStop} aria-label="Stop generation">370                <Square className="size-3.5 fill-current" />371              </Button>372            </Tooltip>373          ) : (374            <Button type="button" size="icon" variant={hasContent ? "accent" : "secondary"} className="tap size-9 shrink-0 rounded-full" onClick={submit} disabled={disabled || !hasContent || uploading > 0} aria-label="Send">375              <ArrowUp />376            </Button>377          )}378        </div>379        {isMobile && leftSlot ? <div className="-mt-1 flex items-center gap-1 overflow-x-auto px-2 pb-1.5 scrollbar-none">{leftSlot}</div> : null}380      </div>381      {bottomSlot ?? (382        <p className="mt-1.5 hidden items-center justify-center gap-1 text-center text-[11px] text-fg-subtle sm:flex">383          {enterToSend ? (384            <>385              <Kbd>↵</Kbd> send · <Kbd>⇧↵</Kbd> newline386            </>387          ) : (388            <>389              <Kbd>⌘↵</Kbd> send390            </>391          )}392        </p>393      )}394395      <FileLibraryPicker open={libraryOpen} onOpenChange={setLibraryOpen} projectId={projectId} onPick={(files: PickedAttachment[]) => onAttachmentsChange([...attachments, ...files].slice(0, 10))} />396    </div>397  );398});399400function PlusMenu({ items, open, onOpenChange, disabled, activeCount, isMobile }: { items: (ComposerAction | "separator")[]; open: boolean; onOpenChange: (o: boolean) => void; disabled?: boolean; activeCount: number; isMobile: boolean }) {401  const trigger = (402    <Button type="button" variant="ghost" size="icon" className="tap relative size-9 shrink-0 rounded-full" disabled={disabled || items.length === 0} aria-label="Add attachment or option" aria-haspopup="menu" onClick={isMobile ? () => onOpenChange(true) : undefined}>403      <Plus className={cn("transition-transform", open && "rotate-45")} />404      {activeCount > 0 ? <span className="absolute -right-0.5 -top-0.5 flex size-4 items-center justify-center rounded-full bg-accent text-[10px] font-semibold text-accent-fg">{activeCount}</span> : null}405    </Button>406  );407  if (isMobile) {408    const sheetItems: (ActionSheetItem | "separator")[] = items.map((it) => (it === "separator" ? "separator" : { key: it.key, label: it.label, icon: it.icon, hint: it.hint, onSelect: it.onSelect, selected: it.selected, disabled: it.disabled, destructive: it.destructive }));409    return (410      <>411        {trigger}412        <ActionSheet open={open} onOpenChange={onOpenChange} items={sheetItems} />413      </>414    );415  }416  return (417    <DropdownMenu open={open} onOpenChange={onOpenChange}>418      <DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>419      <DropdownMenuContent align="start" side="top" sideOffset={8} className="min-w-[240px]">420        {items.map((it, i) =>421          it === "separator" ? (422            <DropdownMenuSeparator key={`sep-${i}`} />423          ) : (424            <DropdownMenuItem key={it.key} disabled={it.disabled} destructive={it.destructive} onSelect={() => setTimeout(it.onSelect, 10)} className="gap-2.5">425              <span className="text-fg-muted [&_svg]:size-4">{it.icon}</span>426              <span className="min-w-0 flex-1 truncate">{it.label}</span>427              {it.hint ? <span className="ml-2 text-[11px] text-fg-subtle">{it.hint}</span> : null}428              {it.selected ? <span className="ml-2 text-accent">✓</span> : null}429            </DropdownMenuItem>430          ),431        )}432      </DropdownMenuContent>433    </DropdownMenu>434  );435}436