SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
4.9 KB · 74 lines tsx
Raw Blame History
1import { useMemo } from 'react';2import { useQuery } from '@tanstack/react-query';3import { BookOpen, Download, FileSpreadsheet, FileText, Image as ImageIcon, Pin, Wand2, X } from 'lucide-react';4import { useUI } from '@/stores/ui';5import { api, downloadFile } from '@/lib/api';6import type { UploadedFile } from '@/lib/types';7import { useChat } from '@/stores/chat';8import { fmtBytes } from '@/lib/format';910export function ArtifactsPanel({ conversationId, onClose }: { conversationId: string; onClose?: () => void }) {11  const { data: files, refetch } = useQuery({ queryKey: ['files', conversationId], queryFn: () => api<UploadedFile[]>(`/conversations/${conversationId}/files`) });12  const msgs = useChat((s) => s.messages[conversationId] || []);13  const sources = useMemo(() => {14    const seen = new Map<string, { module: string; section: string; url: string; course: string }>();15    for (const m of msgs) for (const t of m.tool_calls || []) if (t.name === 'search_course_content') {16      const list = (t.payload as { sources?: { module: string; section: string; url: string; course: string }[] }).sources || [];17      for (const s of list) seen.set(s.url || s.module + s.section, s);18    }19    return [...seen.values()];20  }, [msgs]);21  const setDraft = useUI((s) => s.setDraft);22  const setPanel = useUI((s) => s.setPanel);2324  const icon = (t: string) => (t === 'xlsx' ? FileSpreadsheet : t === 'image' ? ImageIcon : FileText);25  const pin = async (f: UploadedFile) => {26    await api(`/files/${f.file_id}/pin`, { method: 'POST', body: JSON.stringify({ pinned: !f.pinned }) });27    refetch();28  };2930  return (31    <div className="flex h-full max-h-[80dvh] md:max-h-none flex-col">32      <div className="flex items-center justify-between px-4 py-3 border-b border-neutral-line">33        <h2 className="font-semibold text-uqo-blue-dark">Fichiers et sources</h2>34        {onClose && <button onClick={onClose} className="h-11 w-11 inline-flex items-center justify-center rounded-xl hover:bg-neutral-surface" aria-label="Fermer"><X size={20} /></button>}35      </div>36      <div className="flex-1 overflow-y-auto scroll-thin p-4 space-y-5 pb-[calc(16px+var(--safe-bottom))]">37        <section>38          <h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-muted">Fichiers ({files?.length || 0})</h3>39          {!files?.length && <p className="mt-2 text-sm text-neutral-muted">Les fichiers déposés et les classeurs produits apparaîtront ici (conservés 7 jours, 30 si épinglés).</p>}40          <ul className="mt-2 space-y-1.5">41            {files?.map((f) => {42              const Icon = icon(f.type);43              return (44                <li key={f.file_id} className="flex items-center gap-2 rounded-xl border border-neutral-line p-2">45                  <Icon size={18} className="text-uqo-blue shrink-0" />46                  <span className="min-w-0 flex-1"><span className="block text-sm truncate">{f.filename}</span><span className="block text-[11px] text-neutral-muted">{f.kind === 'upload' ? 'déposé' : 'produit'} · {fmtBytes(f.size)}</span></span>47                  {(f.type === 'xlsx' || f.type === 'csv') && (48                    <button onClick={() => { setDraft(`Modifie le fichier « ${f.filename} » : `); if (window.innerWidth < 768) setPanel(false); onClose?.(); }} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-uqo-blue" aria-label="Modifier avec le tuteur" title="Modifier avec le tuteur"><Wand2 size={15} /></button>49                  )}50                  <button onClick={() => pin(f)} className={`h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface ${f.pinned ? 'text-uqo-blue' : 'text-neutral-muted'}`} aria-label="Épingler"><Pin size={15} /></button>51                  <button onClick={() => downloadFile(f.file_id, f.filename)} className="h-9 w-9 inline-flex items-center justify-center rounded-lg hover:bg-neutral-surface text-neutral-muted" aria-label="Télécharger"><Download size={15} /></button>52                </li>53              );54            })}55          </ul>56        </section>57        <section>58          <h3 className="text-xs font-semibold uppercase tracking-wide text-neutral-muted">Sources du cours citées ({sources.length})</h3>59          <ul className="mt-2 space-y-1.5">60            {sources.map((s) => (61              <li key={s.url + s.section}>62                <a href={s.url || '#'} target="_blank" rel="noopener noreferrer" className="flex gap-2 rounded-xl border border-neutral-line p-2 hover:bg-neutral-surface">63                  <BookOpen size={16} className="text-uqo-blue shrink-0 mt-0.5" />64                  <span className="min-w-0"><span className="block text-sm truncate">{s.module}</span><span className="block text-[11px] text-neutral-muted truncate">{s.course} · {s.section}</span></span>65                </a>66              </li>67            ))}68          </ul>69        </section>70      </div>71    </div>72  );73}74