SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%
7.5 KB · 159 lines tsx
Raw Blame History
1"use client";2import * as React from "react";3import { Check, Library, Search } from "lucide-react";4import { ResponsiveDialog } from "@/components/ui/sheet";5import { Button } from "@/components/ui/button";6import { Input } from "@/components/ui/input";7import { ChipRow } from "@/components/ui/segmented";8import { EmptyState, Skeleton } from "@/components/ui/misc";9import { toast } from "@/components/ui/toast";10import { useApi, api } from "@/lib/client/api";11import { useApp } from "@/components/app/store";12import { errorMessage } from "@/lib/client/humanize";13import type { PublicProjectFile, PublicProject } from "@/lib/client/types";14import { formatTokens, formatRelative, cn } from "@/lib/utils";15import { FileThumb } from "./file-list";16import { formatBytes, kindLabel } from "./format";17import { UploadButton } from "./upload-button";1819export interface PickedAttachment {20  id: string;21  kind: string;22  name: string;23  mimeType: string;24  sizeBytes: number;25  width?: number | null;26  height?: number | null;27}2829type Scope = "project" | "global" | "all";30const MAX_PICK = 10;3132/**33 * Pick stored files (project / global library) to attach to a new message.34 * Multi-select (max 10), search, scope chips (this project / global / all), inline upload.35 * Calls POST /api/library/files/attach which copies the files into `message_attachments` and returns36 * pending attachment descriptors compatible with the composer (same shape as POST /api/attachments).37 */38export function FileLibraryPicker({ open, onOpenChange, onPick, projectId }: { open: boolean; onOpenChange: (o: boolean) => void; onPick: (files: PickedAttachment[]) => void; projectId?: string | null }) {39  const { activeProjectId } = useApp();40  const pid = projectId ?? activeProjectId;41  const { data, isLoading, mutate } = useApi<{ files: PublicProjectFile[] }>(open ? "/api/library/files" : null);42  const { data: projData } = useApi<{ projects: PublicProject[] }>(open ? "/api/projects" : null);43  const [selected, setSelected] = React.useState<Set<string>>(new Set());44  const [q, setQ] = React.useState("");45  const [scope, setScope] = React.useState<Scope>(pid ? "project" : "all");46  const [busy, setBusy] = React.useState(false);47  React.useEffect(() => {48    if (!open) return;49    // eslint-disable-next-line react-hooks/set-state-in-effect50    setSelected(new Set());51    setQ("");52    setScope(pid ? "project" : "all");53  }, [open, pid]);5455  const project = React.useMemo(() => (pid ? projData?.projects.find((p) => p.id === pid) : undefined), [pid, projData]);56  const all = React.useMemo(() => data?.files ?? [], [data]);57  const files = React.useMemo(() => {58    const s = q.trim().toLowerCase();59    return all60      .filter((f) => (scope === "project" ? f.projectId === pid : scope === "global" ? !f.projectId : true))61      .filter((f) => (s ? f.name.toLowerCase().includes(s) || (f.description ?? "").toLowerCase().includes(s) || kindLabel(f.kind).toLowerCase().includes(s) : true));62  }, [all, scope, pid, q]);63  const counts = React.useMemo(() => ({ project: all.filter((f) => f.projectId === pid).length, global: all.filter((f) => !f.projectId).length, all: all.length }), [all, pid]);6465  const toggle = (id: string) =>66    setSelected((s) => {67      const n = new Set(s);68      if (n.has(id)) n.delete(id);69      else if (n.size < MAX_PICK) n.add(id);70      else toast.warning(`You can attach up to ${MAX_PICK} files at once`);71      return n;72    });7374  const selectedTokens = React.useMemo(() => all.filter((f) => selected.has(f.id)).reduce((n, f) => n + (f.estimatedTokens ?? 0), 0), [all, selected]);7576  const attach = async () => {77    setBusy(true);78    try {79      const res = await api<{ attachments: PickedAttachment[] }>("/api/library/files/attach", { method: "POST", json: { fileIds: [...selected] } });80      onPick(res.attachments);81      onOpenChange(false);82    } catch (e) {83      toast.error("Could not attach files", errorMessage(e));84    } finally {85      setBusy(false);86    }87  };8889  return (90    <ResponsiveDialog91      open={open}92      onOpenChange={onOpenChange}93      title="Attach from library"94      description={project ? `Files saved to “${project.name}” and your global library.` : "Files saved to your library."}95      size="md"96      snap="full"97      flush98      header={99        <div className="space-y-2 px-4 pb-2 sm:px-6">100          <div className="relative">101            <Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-fg-subtle" />102            <Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search files…" className="h-10 pl-8 sm:h-9" aria-label="Search files" />103          </div>104          {pid ? <ChipRow value={scope} onChange={setScope} options={[{ value: "project", label: project?.name ?? "This project", count: counts.project }, { value: "global", label: "Global", count: counts.global }, { value: "all", label: "All", count: counts.all }]} /> : null}105        </div>106      }107      footer={108        <div className="flex items-center gap-2">109          <span className="min-w-0 truncate text-[13px] text-fg-muted tabular-nums">110            {selected.size ? `${selected.size} selected${selectedTokens ? ` · ~${formatTokens(selectedTokens)} tokens` : ""}` : "Nothing selected"}111          </span>112          <div className="flex-1" />113          <UploadButton size="sm" variant="ghost" projectId={scope === "global" ? null : pid} onUploaded={() => mutate()}>114            Upload115          </UploadButton>116          <Button disabled={!selected.size} loading={busy} onClick={attach}>117            Attach{selected.size ? ` (${selected.size})` : ""}118          </Button>119        </div>120      }121    >122      <div className="px-4 sm:px-6">123        {isLoading && !data ? (124          <div className="space-y-2 py-2">125            {Array.from({ length: 4 }).map((_, i) => (126              <Skeleton key={i} className="h-12" />127            ))}128          </div>129        ) : files.length === 0 ? (130          <EmptyState icon={<Library />} title={all.length ? "No matching files" : "No stored files yet"} description={all.length ? "Try another search or scope." : "Upload PDFs, images or documents once to a project and reuse them in any chat."} className="my-4 border-0 py-8" />131        ) : (132          <ul className="divide-y divide-hairline">133            {files.map((f) => {134              const on = selected.has(f.id);135              return (136                <li key={f.id}>137                  <button type="button" onClick={() => toggle(f.id)} className={cn("flex min-h-[56px] w-full items-center gap-3 py-2 text-left", on && "text-fg")} aria-pressed={on}>138                    <FileThumb file={f} />139                    <span className="min-w-0 flex-1">140                      <span className="block truncate text-[14px] font-medium">{f.name}</span>141                      <span className="block truncate text-[12px] text-fg-subtle tabular-nums">142                        {kindLabel(f.kind)} · {formatBytes(f.sizeBytes)}143                        {f.estimatedTokens ? ` · ~${formatTokens(f.estimatedTokens)} tokens` : ""} · {formatRelative(f.createdAt)}144                      </span>145                    </span>146                    <span className={cn("flex size-6 shrink-0 items-center justify-center rounded-full border transition-colors", on ? "border-accent bg-accent text-accent-fg" : "border-border-strong")} aria-hidden>147                      {on ? <Check className="size-3.5" /> : null}148                    </span>149                  </button>150                </li>151              );152            })}153          </ul>154        )}155      </div>156    </ResponsiveDialog>157  );158}159