spb/chat-spboucher-ai
Public
1"use client";2import { useEffect, useRef, useState } from "react";3import Link from "next/link";4import { useRouter } from "next/navigation";5import useSWR, { mutate } from "swr";6import { Plus, MessageSquare, FileText, Trash2, Download, Upload, Save, Eye, Pencil, ExternalLink, Brain, LayoutPanelLeft, ShieldCheck } from "lucide-react";7import { api, fetcher, relTime } from "@/lib/client/fetcher";8import type { ArtifactDTO, ConversationDTO, MemoryDTO, ProjectDTO } from "@/lib/client/types";9import { useModels } from "@/lib/client/use-models";10import { Button, Empty, Spinner, Tabs, Textarea, Toggle, toast, cn, Badge } from "../ui";11import { Markdown } from "../chat/markdown";12import { MemoryCard } from "../memory/memory-card";13import { AddMemoryDialog } from "../memory/add-memory-dialog";14import { fmtBytes } from "./project-shared";1516export interface ProjectFile { id: string; filename: string; mime: string; size_bytes: number; page_count: number | null; created_at: string }17interface ToolInfo { name: string; description: string; permission: string; category: string }1819export function ConversationsTab({ project }: { project: ProjectDTO }) {20 const router = useRouter();21 const { data } = useSWR<{ conversations: ConversationDTO[] }>(`/api/conversations?project=${project.id}`, fetcher);22 return (23 <div>24 <div className="mb-3 flex justify-end"><Button variant="primary" onClick={() => router.push(`/?project=${project.id}`)}><Plus className="h-4 w-4" /> Nouvelle conversation dans ce projet</Button></div>25 {!data && <Spinner />}26 {data && !data.conversations.length && <Empty icon={<MessageSquare className="h-8 w-8" />} title="Aucune conversation" hint="Les conversations démarrées depuis ce projet héritent de ses instructions, de son modèle et de sa mémoire." />}27 <div className="space-y-1.5">{data?.conversations.map(c => (28 <Link key={c.id} href={`/c/${c.id}`} className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-2.5 hover:border-border-strong hover:bg-surface-2">29 <MessageSquare className="h-4 w-4 shrink-0 text-muted" />30 <span className="min-w-0 flex-1"><span className="block truncate text-[14px]">{c.title}</span><span className="block text-[12px] text-muted">{c.message_count} messages · {Number(c.total_cost_usd).toFixed(3)} $ · {c.model_id ?? "auto"}</span></span>31 <span className="text-[12px] text-muted">{relTime(c.last_message_at ?? c.created_at)}</span>32 </Link>))}</div>33 </div>34 );35}3637export function InstructionsTab({ project, onSaved }: { project: ProjectDTO; onSaved: () => void }) {38 const { chat } = useModels();39 const { data: settings } = useSWR<{ tools: ToolInfo[] }>("/api/settings", fetcher);40 const [instructions, setInstructions] = useState(project.instructions ?? ""); const [model, setModel] = useState(project.default_model ?? "");41 const [perms, setPerms] = useState<Record<string, boolean>>(() => Object.fromEntries(Object.entries(project.tool_permissions ?? {}).map(([k, v]) => [k, !!v]))); const [busy, setBusy] = useState(false);42 useEffect(() => { setInstructions(project.instructions ?? ""); setModel(project.default_model ?? ""); }, [project.id, project.instructions, project.default_model]);43 const tools = settings?.tools ?? [];44 const allowed = (name: string) => perms[name] ?? true;45 async function save() {46 setBusy(true);47 try { await api(`/api/projects/${project.id}`, { method: "PATCH", json: { instructions: instructions.trim() || null, defaultModel: model || null, toolPermissions: Object.fromEntries(tools.map(t => [t.name, allowed(t.name)])) } }); toast("Instructions enregistrées", "ok"); onSaved(); }48 catch (e) { toast((e as Error).message, "error"); } finally { setBusy(false); }49 }50 return (51 <div className="space-y-5">52 <div>53 <label className="mb-1 block text-xs font-medium text-fg-2">Instructions du projet</label>54 <Textarea value={instructions} onChange={e => setInstructions(e.target.value)} maxLength={20000} className="min-h-44 text-[14px]" placeholder="Contexte, ton, format de réponse attendu, sources à privilégier…" />55 <p className="mt-1.5 flex items-start gap-1.5 text-[12px] text-muted"><ShieldCheck className="mt-0.5 h-3.5 w-3.5 shrink-0" /> Les instructions affinent le comportement du modèle dans ce projet. Elles ne peuvent pas contourner les principes de base ni les règles de sécurité de l'application.</p>56 </div>57 <div>58 <label className="mb-1 block text-xs font-medium text-fg-2">Modèle par défaut</label>59 <select value={model} onChange={e => setModel(e.target.value)} className="input"><option value="">— Suivre le choix du composeur —</option>{chat.map(m => <option key={m.id} value={m.id}>{m.displayName} ({m.provider})</option>)}</select>60 </div>61 <div>62 <label className="mb-1 block text-xs font-medium text-fg-2">Outils autorisés dans ce projet</label>63 {!tools.length && <Spinner />}64 <div className="grid gap-1.5 sm:grid-cols-2">{tools.map(t => (65 <label key={t.name} className="flex items-start gap-3 rounded-xl border border-border bg-surface px-3 py-2.5">66 <input type="checkbox" checked={allowed(t.name)} onChange={e => setPerms(p => ({ ...p, [t.name]: e.target.checked }))} className="mt-1 accent-[var(--accent)]" />67 <span className="min-w-0"><span className="flex items-center gap-1.5 text-[13px] font-medium"><code className="font-mono">{t.name}</code><Badge tone={t.permission === "auto" ? "neutral" : "warn"}>{t.permission === "auto" ? "auto" : "approbation"}</Badge></span><span className="block text-[12px] text-muted">{t.description}</span></span>68 </label>))}</div>69 </div>70 <div className="flex justify-end"><Button variant="primary" loading={busy} onClick={save}><Save className="h-4 w-4" /> Enregistrer</Button></div>71 </div>72 );73}7475export function FilesTab({ project, files, onChanged }: { project: ProjectDTO; files: ProjectFile[]; onChanged: () => void }) {76 const [uploading, setUploading] = useState(false); const [drag, setDrag] = useState(false); const input = useRef<HTMLInputElement>(null);77 async function upload(list: FileList | File[]) {78 const arr = Array.from(list); if (!arr.length) return; setUploading(true);79 try { const fd = new FormData(); for (const f of arr) fd.append("file", f); fd.append("projectId", project.id); await api("/api/attachments", { method: "POST", body: fd }); toast(`${arr.length} fichier(s) ajouté(s)`, "ok"); onChanged(); }80 catch (e) { toast((e as Error).message, "error"); } finally { setUploading(false); }81 }82 async function del(f: ProjectFile) { if (!confirm(`Supprimer « ${f.filename} » ?`)) return; await api(`/api/attachments/${f.id}`, { method: "DELETE" }); onChanged(); }83 return (84 <div>85 <div onDragOver={e => { e.preventDefault(); setDrag(true); }} onDragLeave={() => setDrag(false)} onDrop={e => { e.preventDefault(); setDrag(false); void upload(e.dataTransfer.files); }} onClick={() => input.current?.click()} className={cn("mb-4 flex cursor-pointer flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed px-4 py-8 text-center transition-colors", drag ? "border-accent bg-accent/5" : "border-border hover:border-border-strong hover:bg-surface")}>86 <input ref={input} type="file" multiple hidden accept="image/*,.pdf,.docx,.xlsx,.csv,.txt,.md,.json" onChange={e => { if (e.target.files) void upload(e.target.files); e.target.value = ""; }} />87 {uploading ? <Spinner className="h-5 w-5" /> : <Upload className="h-5 w-5 text-accent" />}88 <div className="text-[14px] font-medium">{uploading ? "Téléversement…" : "Dépose des fichiers ici ou clique pour choisir"}</div>89 <div className="text-[12px] text-muted">PDF, DOCX, XLSX, CSV, TXT, Markdown, images · texte extrait et indexé pour l'outil <code className="font-mono">search_documents</code></div>90 </div>91 {!files.length && <Empty icon={<FileText className="h-8 w-8" />} title="Aucun fichier" hint="Les documents du projet sont consultables par le modèle dans toutes les conversations du projet." />}92 <div className="space-y-1.5">{files.map(f => (93 <div key={f.id} className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-2.5">94 <FileText className="h-4 w-4 shrink-0 text-accent-2" />95 <span className="min-w-0 flex-1"><span className="block truncate text-[14px]">{f.filename}</span><span className="block text-[12px] text-muted">{fmtBytes(f.size_bytes)}{f.page_count ? ` · ${f.page_count} p.` : ""} · {relTime(f.created_at)}</span></span>96 <a href={`/api/attachments/${f.id}?download=1`} className="rounded-lg p-1.5 text-fg-2 hover:bg-surface-2 hover:text-fg" title="Télécharger"><Download className="h-4 w-4" /></a>97 <button onClick={() => del(f)} className="rounded-lg p-1.5 text-fg-2 hover:bg-surface-2 hover:text-danger" title="Supprimer"><Trash2 className="h-4 w-4" /></button>98 </div>))}</div>99 </div>100 );101}102103export function MemoryTab({ project, memories, onChanged }: { project: ProjectDTO; memories: MemoryDTO[]; onChanged: () => void }) {104 const [add, setAdd] = useState(false);105 return (106 <div>107 <div className="mb-3 flex items-center justify-between gap-3"><p className="text-[13px] text-muted">Souvenirs propres à ce projet : injectés seulement dans ses conversations, en plus de la mémoire globale.</p><Button variant="primary" onClick={() => setAdd(true)}><Plus className="h-4 w-4" /> Ajouter</Button></div>108 {!memories.length && <Empty icon={<Brain className="h-8 w-8" />} title="Aucun souvenir de projet" />}109 <div className="space-y-2">{memories.map(m => <MemoryCard key={m.id} m={m} candidate={m.status === "candidate"} onChanged={onChanged} />)}</div>110 <AddMemoryDialog open={add} onClose={() => setAdd(false)} onAdded={onChanged} projectId={project.id} />111 </div>112 );113}114115export function NotesTab({ project }: { project: ProjectDTO }) {116 const [notes, setNotes] = useState(project.notes ?? ""); const [preview, setPreview] = useState(false); const [state, setState] = useState<"idle" | "saving" | "saved">("idle"); const first = useRef(true);117 useEffect(() => { setNotes(project.notes ?? ""); first.current = true; }, [project.id]);118 useEffect(() => {119 if (first.current) { first.current = false; return; }120 setState("saving");121 const t = setTimeout(async () => { try { await api(`/api/projects/${project.id}`, { method: "PATCH", json: { notes } }); setState("saved"); void mutate("/api/projects"); } catch { setState("idle"); toast("Échec de la sauvegarde des notes", "error"); } }, 900);122 return () => clearTimeout(t);123 }, [notes, project.id]);124 return (125 <div>126 <div className="mb-2 flex items-center gap-2"><Tabs value={preview ? "preview" : "edit"} onChange={v => setPreview(v === "preview")} items={[{ value: "edit", label: <span className="flex items-center gap-1"><Pencil className="h-3.5 w-3.5" /> Éditer</span> }, { value: "preview", label: <span className="flex items-center gap-1"><Eye className="h-3.5 w-3.5" /> Aperçu</span> }]} /><span className="ml-auto text-[12px] text-muted">{state === "saving" ? "Enregistrement…" : state === "saved" ? "Enregistré" : "Sauvegarde automatique"}</span></div>127 {preview ? <div className="card min-h-64 p-5">{notes.trim() ? <Markdown text={notes} /> : <span className="text-muted">Rien à afficher.</span>}</div>128 : <Textarea value={notes} onChange={e => setNotes(e.target.value)} className="min-h-72 font-mono text-[13px]" placeholder="Notes de recherche, liens, listes… en Markdown." />}129 </div>130 );131}132133export function ArtifactsTab({ artifacts }: { artifacts: ArtifactDTO[] }) {134 if (!artifacts.length) return <Empty icon={<LayoutPanelLeft className="h-8 w-8" />} title="Aucun artefact sauvegardé" hint="Depuis une conversation, ouvre un artefact (HTML, React, SVG, Mermaid…) puis « Sauver au projet »." />;135 return <div className="space-y-1.5">{artifacts.map(a => (136 <a key={a.id} href={`/api/artifacts/${a.id}?raw=1`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-2.5 hover:border-border-strong hover:bg-surface-2">137 <LayoutPanelLeft className="h-4 w-4 shrink-0 text-accent" />138 <span className="min-w-0 flex-1"><span className="block truncate text-[14px]">{a.title}</span><span className="block text-[12px] text-muted">{a.type}{a.language ? ` · ${a.language}` : ""} · v{a.version} · {relTime(a.created_at)}</span></span>139 <ExternalLink className="h-4 w-4 text-muted" />140 </a>))}</div>;141}142143export function ToggleRow({ label, hint, checked, onChange }: { label: string; hint?: string; checked: boolean; onChange: (v: boolean) => void }) {144 return <label className="flex items-center justify-between gap-3 rounded-xl border border-border bg-surface px-3.5 py-3"><span><span className="block text-[13px] font-medium">{label}</span>{hint && <span className="block text-[12px] text-muted">{hint}</span>}</span><Toggle checked={checked} onChange={onChange} label={label} /></label>;145}146