diff --git a/src/app/(app)/admin/audit/page.tsx b/src/app/(app)/admin/audit/page.tsx index 637071f..5152371 100644 --- a/src/app/(app)/admin/audit/page.tsx +++ b/src/app/(app)/admin/audit/page.tsx @@ -1,28 +1,41 @@ -import { desc, eq } from "drizzle-orm"; +import { and, count, desc, eq, gte, lte } from "drizzle-orm"; +import Link from "next/link"; +import { IconDownload } from "@tabler/icons-react"; import { db } from "@/db"; import { auditLog, users } from "@/db/schema"; import { requireAdmin } from "@/lib/auth/permissions"; +import { labelForAction, AUDIT_ACTION_OPTIONS } from "@/lib/audit/labels"; +import { EmptyState } from "@/components/empty-state"; -const ACTION_LABEL: Record = { - "user.create": "Utilisateur créé", - "user.update": "Utilisateur modifié", - "user.disable": "Utilisateur désactivé", - "user.enable": "Utilisateur réactivé", - "user.delete": "Utilisateur supprimé", - "user.role": "Rôle modifié", - "provider.add": "Clé provider ajoutée", - "provider.delete": "Clé provider supprimée", - "provider.toggle": "Clé provider activée/désactivée", - "connector.add": "Connecteur ajouté", - "connector.delete": "Connecteur supprimé", - "doc.delete": "Document supprimé", - "cabinet.update": "Configuration cabinet modifiée", - "auth.login": "Connexion", - "auth.login.failed": "Échec de connexion", -}; +const PAGE_SIZE = 50; -export default async function AdminAuditPage() { +type SP = { action?: string; from?: string; to?: string; page?: string }; + +export default async function AdminAuditPage({ + searchParams, +}: { + searchParams: Promise; +}) { await requireAdmin(); + const sp = await searchParams; + + const page = Math.max(0, Number.parseInt(sp.page ?? "0", 10) || 0); + const action = sp.action && sp.action !== "all" ? sp.action : null; + const from = sp.from ? new Date(sp.from) : null; + const to = sp.to ? new Date(`${sp.to}T23:59:59`) : null; + + const conds = []; + if (action) conds.push(eq(auditLog.action, action)); + if (from && !Number.isNaN(from.getTime())) + conds.push(gte(auditLog.createdAt, from)); + if (to && !Number.isNaN(to.getTime())) conds.push(lte(auditLog.createdAt, to)); + const where = conds.length > 0 ? and(...conds) : undefined; + + const [{ total }] = await db + .select({ total: count() }) + .from(auditLog) + .where(where); + const rows = await db .select({ id: auditLog.id, @@ -35,56 +48,174 @@ export default async function AdminAuditPage() { }) .from(auditLog) .leftJoin(users, eq(users.id, auditLog.userId)) + .where(where) .orderBy(desc(auditLog.createdAt)) - .limit(200); + .limit(PAGE_SIZE) + .offset(page * PAGE_SIZE); + + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + const filterQs = new URLSearchParams(); + if (action) filterQs.set("action", action); + if (sp.from) filterQs.set("from", sp.from); + if (sp.to) filterQs.set("to", sp.to); + const exportQs = filterQs.toString(); + const pageQs = (p: number) => { + const q = new URLSearchParams(filterQs); + if (p > 0) q.set("page", String(p)); + const s = q.toString(); + return s ? `/admin/audit?${s}` : "/admin/audit"; + }; return (
-
-

- Journal d'audit -

-

- 200 dernières actions enregistrées : créations/modifications de - comptes, MAJ providers, suppressions, événements d'authentification. - Append-only. -

+
+
+

+ Journal d'audit +

+

+ {total} action{total > 1 ? "s" : ""} enregistrée{total > 1 ? "s" : ""}. + Append-only — créations/modifications de comptes, providers, + connecteurs, suppressions, authentification. +

+
+
+ {/* Filtres — formulaire GET, server component */} +
+ + + + + {(action || sp.from || sp.to) && ( + + Réinitialiser + + )} +
+ {rows.length === 0 ? ( -
-

Journal vide.

-

- Les actions admin et les événements de sécurité seront enregistrés - ici dès qu'ils auront lieu. -

-
+ + {total === 0 + ? "Les actions admin et les événements de sécurité seront enregistrés ici dès qu'ils auront lieu." + : "Élargissez la période ou changez l'action filtrée."} + ) : (
    {rows.map((r) => ( -
  • - - {ACTION_LABEL[r.action] ?? r.action} - - - {r.actorName ?? r.actorEmail ?? système} - {r.target && ( - <> - {" "} - → {r.target} - - )} - - +
  • +
    + + {labelForAction(r.action)} + + + {r.actorName ?? r.actorEmail ?? système} + {r.target && → {r.target}} + + +
    + {r.meta != null && Object.keys(r.meta as object).length > 0 && ( +

    + {Object.entries(r.meta as Record) + .map(([k, v]) => `${k}: ${String(v)}`) + .join(" · ")} +

    + )}
  • ))}
)} + + {totalPages > 1 && ( + + )}
); } diff --git a/src/app/(app)/admin/cabinet/cabinet-form.tsx b/src/app/(app)/admin/cabinet/cabinet-form.tsx index 47d4a2e..dd6408d 100644 --- a/src/app/(app)/admin/cabinet/cabinet-form.tsx +++ b/src/app/(app)/admin/cabinet/cabinet-form.tsx @@ -64,7 +64,7 @@ export function CabinetForm({ maxLength={1000} defaultValue={initial?.legalDisclaimer ?? ""} placeholder="Document généré par Louis. Ne constitue pas un conseil juridique personnalisé sans validation par un avocat." - className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />

Ajoutée en dernière page des documents générés. Vous pouvez la diff --git a/src/app/(app)/admin/users/[id]/page.tsx b/src/app/(app)/admin/users/[id]/page.tsx index 610b3bf..9c33789 100644 --- a/src/app/(app)/admin/users/[id]/page.tsx +++ b/src/app/(app)/admin/users/[id]/page.tsx @@ -21,6 +21,7 @@ import { } from "@/db/schema"; import { requireAdmin } from "@/lib/auth/permissions"; import { aggregateCosts, formatTotals } from "@/lib/providers/pricing"; +import { labelForAction } from "@/lib/audit/labels"; import { Badge } from "@/components/ui/badge"; export default async function AdminUserDetailPage({ @@ -261,7 +262,7 @@ export default async function AdminUserDetailPage({ className="py-3 flex items-start justify-between gap-3" >

-

{a.action}

+

{labelForAction(a.action)}

{a.target && (

{a.target} diff --git a/src/app/(app)/admin/users/user-row.tsx b/src/app/(app)/admin/users/user-row.tsx index b9930d1..79aa01e 100644 --- a/src/app/(app)/admin/users/user-row.tsx +++ b/src/app/(app)/admin/users/user-row.tsx @@ -13,6 +13,7 @@ import { IconTrash, } from "@tabler/icons-react"; import { Badge } from "@/components/ui/badge"; +import { formatRelativeFr } from "@/lib/format/time"; import { DropdownMenu, DropdownMenuContent, @@ -63,20 +64,6 @@ export type UserStats = { export type UserEntryWithStats = Entry & { stats: UserStats }; -function formatRelativeFr(d: Date | string | null): string { - if (!d) return "jamais utilisé"; - const date = typeof d === "string" ? new Date(d) : d; - const ms = Date.now() - date.getTime(); - const m = Math.floor(ms / 60_000); - if (m < 1) return "à l'instant"; - if (m < 60) return `il y a ${m} min`; - const h = Math.floor(m / 60); - if (h < 24) return `il y a ${h} h`; - const days = Math.floor(h / 24); - if (days < 30) return `il y a ${days} j`; - if (days < 365) return `il y a ${Math.floor(days / 30)} mois`; - return date.toLocaleDateString("fr-FR"); -} function formatEurFromCents(cents: number | null): string { if (cents == null) return "—"; @@ -86,7 +73,7 @@ function formatEurFromCents(cents: number | null): string { function StatCell({ label, value }: { label: string; value: number }) { return (

-
+
{label}
{value}
@@ -218,7 +205,13 @@ export function UserRow({ : "jamais utilisé"}
{feedback && ( -
{feedback}
+
+ {feedback} +
)} {/* Résumé compact des stats sur mobile : la rangée détaillée @@ -273,7 +266,7 @@ export function UserRow({
-
+
Ce mois
setQuery(e.target.value)} placeholder="Rechercher par email ou nom…" aria-label="Rechercher un utilisateur" - className="w-full rounded-md border border-input bg-background pl-8 pr-3 py-1.5 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + className="w-full rounded-md border border-input bg-background pl-8 pr-3 py-1.5 text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
diff --git a/src/app/(app)/board/[id]/add-agent-dialog.tsx b/src/app/(app)/board/[id]/add-agent-dialog.tsx index d7136c5..1d7291b 100644 --- a/src/app/(app)/board/[id]/add-agent-dialog.tsx +++ b/src/app/(app)/board/[id]/add-agent-dialog.tsx @@ -54,8 +54,10 @@ type Role = const ROLE_OPTIONS: Role[] = [ "default-chat", "research", + "legifrance", "citator", "reviewer", + "drafting", "orchestrator", ]; diff --git a/src/app/(app)/board/[id]/agent-flow-node.tsx b/src/app/(app)/board/[id]/agent-flow-node.tsx index 9891a7d..7b6ba84 100644 --- a/src/app/(app)/board/[id]/agent-flow-node.tsx +++ b/src/app/(app)/board/[id]/agent-flow-node.tsx @@ -5,6 +5,8 @@ import { Handle, Position, type NodeProps } from "@xyflow/react"; import { IconAlertTriangle, IconCheck, + IconDatabase, + IconDatabaseOff, IconPencil, IconTrash, } from "@tabler/icons-react"; @@ -12,6 +14,26 @@ import { cn } from "@/lib/utils"; import type { PipelineAgent, ProviderKey } from "@/db/schema"; import { roleMeta } from "../agent-role-meta"; +/** + * Libellé de transparence de la portée RAG d'un agent. `null` pour le cas par + * défaut (hérite du périmètre de la conversation) afin de ne pas alourdir le + * canvas — on ne signale QUE les agents à portée restreinte. + */ +function ragScopeBadge( + scope: PipelineAgent["ragScope"] +): { label: string; off: boolean } | null { + if (!scope || scope.mode === "inherit" || scope.mode === "project") { + return null; + } + if (scope.mode === "none") return { label: "RAG désactivé", off: true }; + if (scope.mode === "folders") { + const n = scope.folderIds.length; + return { label: `lit : ${n} dossier${n > 1 ? "s" : ""}`, off: false }; + } + const n = scope.documentIds.length; + return { label: `lit : ${n} doc${n > 1 ? "s" : ""}`, off: false }; +} + /** * Données portées par chaque node React Flow. Le composant lit la * définition d'agent + un setter de drawer pour ouvrir l'édition au clic. @@ -42,6 +64,7 @@ function AgentFlowNodeBase({ data }: NodeProps) { const meta = roleMeta(agent.role); const Icon = meta.icon; const provider = providerKeys.find((k) => k.id === agent.providerKeyId); + const rag = ragScopeBadge(agent.ragScope); return (
)} + {/* Handle conservé pour l'ANCRAGE des edges auto-générées, mais invisible + et non-interactif : le graphe n'est pas un éditeur de connexions + (nodesConnectable=false). Avant, des poignées visibles laissaient + croire qu'on pouvait relier les agents à la main (H7). */} {/* Header */} @@ -159,6 +187,23 @@ function AgentFlowNodeBase({ data }: NodeProps) { {provider.label} )} + {rag && ( + + {rag.off ? ( + + ) : ( + + )} + {rag.label} + + )}
{agent.systemPrompt && ( @@ -187,7 +232,8 @@ function AgentFlowNodeBase({ data }: NodeProps) {
); diff --git a/src/app/(app)/board/[id]/execution-order-panel.tsx b/src/app/(app)/board/[id]/execution-order-panel.tsx new file mode 100644 index 0000000..6b09649 --- /dev/null +++ b/src/app/(app)/board/[id]/execution-order-panel.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { IconArrowUp, IconArrowDown, IconFlag } from "@tabler/icons-react"; +import type { PipelineAgent } from "@/db/schema"; +import { roleMeta } from "../agent-role-meta"; +import { reorderPipelineAgents } from "../actions"; + +const MODE_HINT: Record = { + sequential: + "Chaîne : chaque agent voit la sortie des précédents. Le dernier agent rend la réponse finale.", + council: + "Conseil : tous les agents délibèrent (N tours). Le dernier agent — le terminal — synthétise et rend la réponse.", + parallel: + "Parallèle : les agents travaillent en même temps sur la question. Le dernier agent — le terminal — agrège et rend la réponse.", +}; + +/** + * Panneau d'ordre d'exécution explicite (Lot 3) : liste numérotée, + * réordonnable au clavier/souris via des flèches haut/bas, indépendante de la + * géométrie du canvas. Rend visible quel agent est TERMINAL (« répond en + * dernier ») dans les trois modes. Persiste via reorderPipelineAgents + * (réindexe `position`). + */ +export function ExecutionOrderPanel({ + pipelineId, + agents, + mode, + editable, +}: { + pipelineId: string; + agents: PipelineAgent[]; + mode: "sequential" | "council" | "parallel"; + editable: boolean; +}) { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + const [order, setOrder] = useState( + [...agents].sort((a, b) => a.position - b.position) + ); + + // Resync quand les agents changent côté serveur (ajout/suppression/refresh). + // Pattern React 19 « set state during render » (pas d'effet). + const propKey = agents.map((a) => a.id).join("|"); + const [seenKey, setSeenKey] = useState(propKey); + if (propKey !== seenKey) { + setSeenKey(propKey); + setOrder([...agents].sort((a, b) => a.position - b.position)); + } + + function move(index: number, dir: -1 | 1) { + const target = index + dir; + if (target < 0 || target >= order.length) return; + const next = [...order]; + [next[index], next[target]] = [next[target], next[index]]; + setOrder(next); + startTransition(async () => { + const result = await reorderPipelineAgents( + pipelineId, + next.map((a) => a.id) + ); + if (!result.ok) toast.error(result.error); + router.refresh(); + }); + } + + if (agents.length <= 1) return null; + + return ( +
+
+

Ordre d'exécution

+

+ {MODE_HINT[mode] ?? MODE_HINT.sequential} +

+
+
    + {order.map((a, i) => { + const meta = roleMeta(a.role); + const Icon = meta.icon; + const isTerminal = i === order.length - 1; + return ( +
  1. + + {i + 1} + + + + {a.label} + + {isTerminal && ( + + répond en dernier + + )} + {editable && ( +
    + + +
    + )} +
  2. + ); + })} +
+
+ ); +} diff --git a/src/app/(app)/board/[id]/page.tsx b/src/app/(app)/board/[id]/page.tsx index c2115c3..bec962d 100644 --- a/src/app/(app)/board/[id]/page.tsx +++ b/src/app/(app)/board/[id]/page.tsx @@ -8,11 +8,16 @@ import { providerKeys } from "@/db/schema"; import { getPipeline } from "../actions"; import { PipelineActionsMenu } from "../pipeline-actions-menu"; import { PipelineWorkflow } from "./pipeline-workflow"; +import { PipelineBoard } from "../pipeline-board"; import { CloneToEditButton } from "./clone-to-edit-button"; import { PipelineModeBar } from "./pipeline-mode-bar"; import { AddAgentDialog } from "./add-agent-dialog"; +import { ExecutionOrderPanel } from "./execution-order-panel"; import { InlineRename } from "./inline-rename"; import { listEnabledModels } from "../../settings/models/actions"; +import { buildToolsForUser } from "@/lib/connectors/tools"; +import { buildMcpToolsForUser } from "@/lib/mcp/tools"; +import { getAgentSourceOptions } from "@/lib/projects/scope"; export default async function PipelineEditorPage({ params, @@ -45,6 +50,24 @@ export default async function PipelineEditorPage({ hint: r.hint, })); + // H11 : outils RÉELLEMENT disponibles pour cet utilisateur (connecteurs + // actifs + génération de documents + RAG si dispo + outils MCP synchronisés). + // Sert au multi-select de l'allowlist d'agent (fin du champ texte libre où + // une typo créait un agent sans outil, silencieusement). + const [connectorTools, mcpTools] = await Promise.all([ + buildToolsForUser(userId), + buildMcpToolsForUser(userId), + ]); + const availableTools = [ + ...Object.keys(connectorTools), + ...Object.keys(mcpTools), + ].sort((a, b) => a.localeCompare(b)); + + // Sources documentaires sélectionnables pour la portée RAG par agent + // (dossiers en arborescence + documents avec flag indexé). + const { folders: availableFolders, documents: availableDocuments } = + await getAgentSourceOptions(userId); + return (
@@ -53,7 +76,7 @@ export default async function PipelineEditorPage({ className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors mb-4" > - Bureau + Board
@@ -105,14 +128,33 @@ export default async function PipelineEditorPage({
- + {/* Desktop : canvas React Flow. */} +
+ +
+ {/* Mobile (H7) : vue-liste verticale — le canvas est inutilisable au + doigt sous 640px. */} +
+ +
{!data.pipeline.isPreset && ( -
+
· Chaque exécution est tracée dans l'audit
+ +
); } diff --git a/src/app/(app)/board/[id]/pipeline-mode-bar.tsx b/src/app/(app)/board/[id]/pipeline-mode-bar.tsx index 2ec200a..35dd359 100644 --- a/src/app/(app)/board/[id]/pipeline-mode-bar.tsx +++ b/src/app/(app)/board/[id]/pipeline-mode-bar.tsx @@ -12,6 +12,7 @@ import { } from "@/components/ui/select"; import type { Pipeline } from "@/db/schema"; import { cn } from "@/lib/utils"; +import { estimateCalls } from "@/lib/orchestrator/cost-estimate"; import { MODE_META, type PipelineModeKey } from "../mode-meta"; import { updatePipelineMeta } from "../actions"; @@ -151,7 +152,7 @@ export function PipelineModeBar({ pipeline, agentCount }: PipelineModeBarProps)

{(() => { const debaters = Math.max(0, agentCount - 1); - const calls = rounds * debaters + 1; + const calls = estimateCalls({ mode: "council", agents: agentCount, rounds }); return ( <> diff --git a/src/app/(app)/board/[id]/pipeline-workflow.tsx b/src/app/(app)/board/[id]/pipeline-workflow.tsx index 4fd497a..83a61ce 100644 --- a/src/app/(app)/board/[id]/pipeline-workflow.tsx +++ b/src/app/(app)/board/[id]/pipeline-workflow.tsx @@ -17,6 +17,10 @@ import { } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { Pipeline, PipelineAgent, ProviderKey } from "@/db/schema"; +import type { + AgentSourceFolder, + AgentSourceDocument, +} from "@/lib/projects/scope"; import { AgentEditSheet } from "../agent-edit-sheet"; import { AgentFlowNode, type AgentFlowNodeData } from "./agent-flow-node"; import { AnimatedEdge } from "./animated-edge"; @@ -52,6 +56,11 @@ interface PipelineWorkflowProps { hint?: string | null; }>; liveStates?: Record; + /** Outils réellement disponibles pour l'utilisateur (multi-select allowlist). */ + availableTools?: string[]; + /** Dossiers/documents de l'utilisateur (sélecteurs de portée RAG par agent). */ + availableFolders?: AgentSourceFolder[]; + availableDocuments?: AgentSourceDocument[]; } const nodeTypes: NodeTypes = { @@ -180,6 +189,9 @@ function PipelineWorkflowInner({ providerKeys, enabledModels, liveStates, + availableTools, + availableFolders, + availableDocuments, }: PipelineWorkflowProps) { const router = useRouter(); const [editingAgent, setEditingAgent] = useState(null); @@ -187,7 +199,9 @@ function PipelineWorkflowInner({ const [pending, startTransition] = useTransition(); const editable = !pipeline.isPreset && !pending; const mode = (pipeline.mode as "sequential" | "council" | "parallel") ?? "sequential"; - const dragEnabled = editable && agents.length > 1; + // H7 : le drag ne ré-ordonne l'exécution QU'en mode séquentiel (en + // council/parallel, la position n'a aucun effet sur l'ordre → drag trompeur). + const dragEnabled = editable && mode === "sequential" && agents.length > 1; const handleDelete = useCallback((agent: PipelineAgent) => { setPendingDelete(agent); @@ -421,6 +435,9 @@ function PipelineWorkflowInner({ agent={editingAgent} providerKeys={providerKeys} enabledModels={enabledModels} + availableTools={availableTools} + availableFolders={availableFolders} + availableDocuments={availableDocuments} open={!!editingAgent} onOpenChange={(open) => { if (!open) setEditingAgent(null); diff --git a/src/app/(app)/board/actions.ts b/src/app/(app)/board/actions.ts index 8eaa0eb..ffe96d0 100644 --- a/src/app/(app)/board/actions.ts +++ b/src/app/(app)/board/actions.ts @@ -24,6 +24,24 @@ async function requireUserId(): Promise { return session.user.id; } +const pipelineMetaSchema = z.object({ + name: z.string().trim().min(1).max(120), + description: z.string().trim().max(500).nullable().optional(), + mode: z.enum(["sequential", "council", "parallel"]).optional(), + rounds: z.number().int().min(1).max(6).optional(), +}); + +const ragScopeSchema = z.discriminatedUnion("mode", [ + z.object({ mode: z.literal("inherit") }), + z.object({ mode: z.literal("none") }), + z.object({ mode: z.literal("project") }), + z.object({ mode: z.literal("folders"), folderIds: z.array(z.uuid()).max(50) }), + z.object({ + mode: z.literal("documents"), + documentIds: z.array(z.uuid()).max(200), + }), +]); + const AGENT_ROLE_VALUES = [ "default-chat", "orchestrator", @@ -34,19 +52,15 @@ const AGENT_ROLE_VALUES = [ "legifrance", ] as const; -const pipelineMetaSchema = z.object({ - name: z.string().trim().min(1).max(120), - description: z.string().trim().max(500).nullable().optional(), - mode: z.enum(["sequential", "council", "parallel"]).optional(), - rounds: z.number().int().min(1).max(6).optional(), -}); - const agentUpdateSchema = z.object({ label: z.string().trim().min(1).max(80).optional(), + role: z.enum(AGENT_ROLE_VALUES).optional(), providerKeyId: z.uuid().nullable().optional(), modelOverride: z.string().trim().max(120).nullable().optional(), systemPrompt: z.string().max(8000).nullable().optional(), toolAllowlist: z.array(z.string()).nullable().optional(), + ragScope: ragScopeSchema.nullable().optional(), + temperature: z.number().min(0).max(2).nullable().optional(), }); const agentInsertSchema = z.object({ @@ -263,6 +277,7 @@ export async function updatePipelineAgent( .update(pipelineAgents) .set({ ...(parsed.data.label !== undefined && { label: parsed.data.label }), + ...(parsed.data.role !== undefined && { role: parsed.data.role }), ...(parsed.data.providerKeyId !== undefined && { providerKeyId: parsed.data.providerKeyId, }), @@ -275,6 +290,12 @@ export async function updatePipelineAgent( ...(parsed.data.toolAllowlist !== undefined && { toolAllowlist: parsed.data.toolAllowlist, }), + ...(parsed.data.ragScope !== undefined && { + ragScope: parsed.data.ragScope, + }), + ...(parsed.data.temperature !== undefined && { + temperature: parsed.data.temperature, + }), updatedAt: new Date(), }) .where(eq(pipelineAgents.id, agentId)); diff --git a/src/app/(app)/board/agent-edit-sheet.tsx b/src/app/(app)/board/agent-edit-sheet.tsx index 90d8edc..7835353 100644 --- a/src/app/(app)/board/agent-edit-sheet.tsx +++ b/src/app/(app)/board/agent-edit-sheet.tsx @@ -23,9 +23,14 @@ import { SelectValue, } from "@/components/ui/select"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import type { PipelineAgent, ProviderKey } from "@/db/schema"; +import type { PipelineAgent, ProviderKey, AgentRagScope } from "@/db/schema"; +import type { + AgentSourceFolder, + AgentSourceDocument, +} from "@/lib/projects/scope"; import { MODEL_CATALOG } from "@/lib/providers/models"; -import { roleMeta } from "./agent-role-meta"; +import type { AgentRole } from "@/lib/orchestrator"; +import { roleMeta, AGENT_ROLES } from "./agent-role-meta"; import { updatePipelineAgent } from "./actions"; export interface AgentEditModelOption { @@ -40,10 +45,18 @@ interface AgentEditSheetProps { providerKeys: Pick[]; /** Modèles ajoutés par l'utilisateur via /settings/models/library. */ enabledModels?: AgentEditModelOption[]; + /** Outils réellement disponibles (connecteurs actifs + RAG + MCP). */ + availableTools?: string[]; + /** Dossiers de l'utilisateur (sélecteur de portée RAG « dossiers choisis »). */ + availableFolders?: AgentSourceFolder[]; + /** Documents de l'utilisateur (sélecteur de portée RAG « documents choisis »). */ + availableDocuments?: AgentSourceDocument[]; open: boolean; onOpenChange: (open: boolean) => void; } +type RagMode = "inherit" | "none" | "folders" | "documents"; + const NONE_VALUE = "__none__"; /** @@ -56,6 +69,9 @@ export function AgentEditSheet({ agent, providerKeys, enabledModels, + availableTools = [], + availableFolders = [], + availableDocuments = [], open, onOpenChange, }: AgentEditSheetProps) { @@ -63,17 +79,79 @@ export function AgentEditSheet({ const [pending, startTransition] = useTransition(); const [error, setError] = useState(null); + const [role, setRole] = useState(agent.role as AgentRole); const [label, setLabel] = useState(agent.label); const [providerKeyId, setProviderKeyId] = useState( agent.providerKeyId ?? NONE_VALUE ); const [modelOverride, setModelOverride] = useState(agent.modelOverride ?? ""); + // Température : null en base = défaut du provider. + const [tempMode, setTempMode] = useState<"default" | "custom">( + agent.temperature == null ? "default" : "custom" + ); + const [temperature, setTemperature] = useState( + agent.temperature ?? 0.7 + ); const [systemPrompt, setSystemPrompt] = useState(agent.systemPrompt ?? ""); - const [toolAllowlistRaw, setToolAllowlistRaw] = useState( - serializeAllowlist(agent.toolAllowlist) + // Allowlist : null = tous les outils, [] = aucun, [...] = sélection. Plus de + // champ texte libre (une typo donnait un agent sans outil, silencieux). + const [allowlistMode, setAllowlistMode] = useState<"all" | "custom">( + agent.toolAllowlist == null ? "all" : "custom" + ); + const [selectedTools, setSelectedTools] = useState>( + new Set(agent.toolAllowlist ?? []) + ); + // Portée documentaire RAG. null/inherit/project → « hérite » (périmètre de + // la conversation). folders/documents → restriction par intersection. + const [ragMode, setRagMode] = useState( + agent.ragScope?.mode === "none" + ? "none" + : agent.ragScope?.mode === "folders" + ? "folders" + : agent.ragScope?.mode === "documents" + ? "documents" + : "inherit" + ); + const [ragFolderIds, setRagFolderIds] = useState>( + new Set(agent.ragScope?.mode === "folders" ? agent.ragScope.folderIds : []) + ); + const [ragDocIds, setRagDocIds] = useState>( + new Set( + agent.ragScope?.mode === "documents" ? agent.ragScope.documentIds : [] + ) ); - const meta = roleMeta(agent.role); + function toggleRagFolder(id: string) { + setRagFolderIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + function toggleRagDoc(id: string) { + setRagDocIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + // Outils de l'allowlist héritée qui ne sont plus/pas disponibles côté user. + const unavailableSelected = Array.from(selectedTools).filter( + (t) => !availableTools.includes(t) + ); + + function toggleTool(t: string) { + setSelectedTools((prev) => { + const next = new Set(prev); + if (next.has(t)) next.delete(t); + else next.add(t); + return next; + }); + } + + const meta = roleMeta(role); const Icon = meta.icon; const selectedKey = providerKeys.find((k) => k.id === providerKeyId); // Source de vérité = modèles ajoutés via la bibliothèque. Fallback @@ -95,20 +173,28 @@ export function AgentEditSheet({ function handleSave() { setError(null); + const allowlist = + allowlistMode === "all" ? null : Array.from(selectedTools); - const parsed = parseAllowlist(toolAllowlistRaw); - if (parsed === "invalid") { - setError("Liste d'outils invalide. Format attendu : noms séparés par des virgules."); - return; - } + const ragScope: AgentRagScope | null = + ragMode === "none" + ? { mode: "none" } + : ragMode === "folders" + ? { mode: "folders", folderIds: Array.from(ragFolderIds) } + : ragMode === "documents" + ? { mode: "documents", documentIds: Array.from(ragDocIds) } + : null; startTransition(async () => { const result = await updatePipelineAgent(agent.id, { label: label.trim() || agent.label, + role, providerKeyId: providerKeyId === NONE_VALUE ? null : providerKeyId, modelOverride: modelOverride.trim() || null, + temperature: tempMode === "custom" ? temperature : null, systemPrompt: systemPrompt.trim() ? systemPrompt : null, - toolAllowlist: parsed, + toolAllowlist: allowlist, + ragScope, }); if (result.ok) { onOpenChange(false); @@ -138,6 +224,34 @@ export function AgentEditSheet({

+
+ + + {role !== agent.role ? ( +

+ + + Changer le rôle modifie le prompt « factory » et les outils + par défaut de l'agent. Votre prompt système personnalisé + est conservé. + +

+ ) : ( +

{meta.pitch}

+ )} +
+
+
+
+ +
+ + +
+
+ {tempMode === "custom" ? ( + <> +
+ + setTemperature(parseFloat(e.target.value)) + } + className="flex-1 accent-primary" + aria-describedby={`temp-help-${agent.id}`} + /> + + {temperature.toFixed(1)} + +
+
+ 0 · précis / factuel + créatif · 2 +
+ + ) : ( +

+ Température par défaut du provider — convient à la plupart des + usages. Passez en « Personnalisée » pour un Relecteur très + factuel (bas) ou un Rédacteur plus créatif (haut). +

+ )} +
+
-
+ +
+ + + + {ragMode === "folders" && ( +
+ {availableFolders.length === 0 ? ( +

+ Aucun dossier — créez-en dans l'onglet Documents. +

+ ) : ( + availableFolders.map((f) => ( + + )) + )} +
+ )} + + {ragMode === "documents" && ( +
+ {availableDocuments.length === 0 ? ( +

+ Aucun document importé. +

+ ) : ( + availableDocuments.map((d) => ( + + )) + )} +
+ )} +

- Liste de noms d'outils séparés par des virgules. Vide = tous - les outils disponibles. = aucun outil (l'agent - travaille uniquement sur le texte). + {ragMode === "inherit" + ? "L'agent lit les pièces du projet de la conversation (par défaut)." + : ragMode === "none" + ? "L'agent ne lit aucune pièce — il travaille sans recherche documentaire." + : ragMode === "folders" + ? `${ragFolderIds.size} dossier${ragFolderIds.size > 1 ? "s" : ""} sélectionné${ragFolderIds.size > 1 ? "s" : ""} — intersecté avec le périmètre du projet de la conversation.` + : `${ragDocIds.size} document${ragDocIds.size > 1 ? "s" : ""} sélectionné${ragDocIds.size > 1 ? "s" : ""} — intersecté avec le périmètre du projet de la conversation.`}

@@ -317,20 +654,3 @@ export function AgentEditSheet({ ); } -function serializeAllowlist(allowlist: string[] | null | undefined): string { - if (allowlist === null || allowlist === undefined) return ""; - if (allowlist.length === 0) return "—"; - return allowlist.join(", "); -} - -function parseAllowlist(raw: string): string[] | null | "invalid" { - const trimmed = raw.trim(); - if (trimmed === "") return null; - if (trimmed === "—" || trimmed === "-") return []; - const parts = trimmed - .split(/[,\s]+/) - .map((s) => s.trim()) - .filter(Boolean); - if (parts.some((p) => !/^[a-zA-Z0-9_]+$/.test(p))) return "invalid"; - return parts; -} diff --git a/src/app/(app)/board/agent-role-meta.ts b/src/app/(app)/board/agent-role-meta.ts index 3153345..772b8e0 100644 --- a/src/app/(app)/board/agent-role-meta.ts +++ b/src/app/(app)/board/agent-role-meta.ts @@ -91,3 +91,14 @@ export const AGENT_ROLE_META: Record< export function roleMeta(role: string) { return AGENT_ROLE_META[role as AgentRole] ?? AGENT_ROLE_META["default-chat"]; } + +/** Rôles sélectionnables (ordre du plus généraliste au plus terminal). */ +export const AGENT_ROLES: AgentRole[] = [ + "default-chat", + "research", + "legifrance", + "citator", + "drafting", + "reviewer", + "orchestrator", +]; diff --git a/src/app/(app)/board/page.tsx b/src/app/(app)/board/page.tsx index fa0ae2c..3819402 100644 --- a/src/app/(app)/board/page.tsx +++ b/src/app/(app)/board/page.tsx @@ -30,7 +30,7 @@ export default async function BureauPage() {

- Bureau + Board

Votre cabinet d'IA. @@ -120,6 +120,9 @@ export default async function BureauPage() { []; + enabledModels?: AgentEditModelOption[]; + availableTools?: string[]; + availableFolders?: AgentSourceFolder[]; + availableDocuments?: AgentSourceDocument[]; } /** - * Affichage hiérarchique d'une pipeline en mode « board ». L'agent - * terminal (dernier de la séquence) est mis en avant en haut — c'est - * celui dont la réponse arrive à l'utilisateur. Les agents intermédiaires - * sont affichés en grille en dessous, dans leur ordre d'exécution. + * Affichage vertical d'une pipeline — fallback mobile (H7) du canvas React + * Flow, inutilisable sur petit écran. L'agent terminal (dernier de la + * séquence) est mis en avant en haut — c'est celui dont la réponse arrive à + * l'utilisateur ; les agents intermédiaires sont empilés en dessous dans + * leur ordre d'exécution. * * Pour les pipelines mono-agent (chat-simple), on affiche juste la seule - * carte centrée — pas de hiérarchie artificielle. + * carte — pas de hiérarchie artificielle. */ export function PipelineBoard({ pipeline, agents, providerKeys, + enabledModels, + availableTools, + availableFolders, + availableDocuments, }: PipelineBoardProps) { const [editingAgent, setEditingAgent] = useState(null); @@ -69,12 +82,7 @@ export function PipelineBoard({
s'appuie sur
-
+
{team.map((a, i) => (
@@ -98,6 +106,10 @@ export function PipelineBoard({ { if (!open) setEditingAgent(null); diff --git a/src/app/(app)/board/try-pipeline-button.tsx b/src/app/(app)/board/try-pipeline-button.tsx index ba569dc..f5c3a73 100644 --- a/src/app/(app)/board/try-pipeline-button.tsx +++ b/src/app/(app)/board/try-pipeline-button.tsx @@ -2,6 +2,8 @@ import { useRouter } from "next/navigation"; import { IconBolt } from "@tabler/icons-react"; +import { estimateCalls } from "@/lib/orchestrator/cost-estimate"; +import type { PipelineMode } from "@/lib/orchestrator/types"; /** * CTA "Essayer" sur une card de pipeline — démarre une nouvelle @@ -26,10 +28,23 @@ const SAMPLE_PROMPTS: Record = { interface TryPipelineButtonProps { pipelineId: string; slug: string; + mode: PipelineMode; + agentCount: number; + rounds: number | null; } -export function TryPipelineButton({ pipelineId, slug }: TryPipelineButtonProps) { +export function TryPipelineButton({ + pipelineId, + slug, + mode, + agentCount, + rounds, +}: TryPipelineButtonProps) { const router = useRouter(); + // Nombre d'appels LLM que ce pipeline déclenchera — affiché sur le CTA + // pour que le coût soit visible AVANT de lancer (un comité 3 agents/2 tours + // = 5 appels, pas 1). + const calls = estimateCalls({ mode, agents: agentCount, rounds: rounds ?? 1 }); function handleClick(e: React.MouseEvent) { e.preventDefault(); @@ -46,9 +61,10 @@ export function TryPipelineButton({ pipelineId, slug }: TryPipelineButtonProps) type="button" onClick={handleClick} className="inline-flex items-center gap-1 text-xs text-foreground/70 hover:text-foreground transition-colors" + title={`~${calls} appel${calls > 1 ? "s" : ""} LLM par question`} > - Essayer + Essayer{calls > 1 ? ` · ~${calls} appels` : ""} ); } diff --git a/src/app/(app)/chat/actions.ts b/src/app/(app)/chat/actions.ts index 5723e93..7d9c34d 100644 --- a/src/app/(app)/chat/actions.ts +++ b/src/app/(app)/chat/actions.ts @@ -6,7 +6,7 @@ import { and, asc, eq, gt } from "drizzle-orm"; import { z } from "zod"; import { auth } from "@/auth"; import { db } from "@/db"; -import { conversations, messages } from "@/db/schema"; +import { agentRuns, conversations, messages } from "@/db/schema"; const titleSchema = z.string().trim().min(1).max(120); @@ -139,6 +139,137 @@ export async function editUserMessageAndTrim( return { ok: true }; } +export type AuditRunView = { + messageId: string | null; + role: string; + label: string; + modelId: string | null; + providerType: string | null; + inputTokens: number | null; + outputTokens: number | null; + latencyMs: number | null; + status: string; + error: string | null; + startedAt: Date; + finishedAt: Date | null; +}; + +/** + * H3b : trail d'audit multi-agents d'une conversation, groupé par message + * assistant (agent_runs.messageId, rattaché côté route — P1/H9). Vérifie la + * propriété de la conversation. Sert à l'affichage et à l'export (H5). + */ +export async function getConversationAuditTrail( + conversationId: string +): Promise<{ ok: true; runs: AuditRunView[] } | { ok: false }> { + const userId = await requireUserId(); + const [conv] = await db + .select({ id: conversations.id }) + .from(conversations) + .where( + and( + eq(conversations.id, conversationId), + eq(conversations.userId, userId) + ) + ) + .limit(1); + if (!conv) return { ok: false }; + + const runs = await db + .select({ + messageId: agentRuns.messageId, + role: agentRuns.role, + label: agentRuns.label, + modelId: agentRuns.modelId, + providerType: agentRuns.providerType, + inputTokens: agentRuns.inputTokens, + outputTokens: agentRuns.outputTokens, + latencyMs: agentRuns.latencyMs, + status: agentRuns.status, + error: agentRuns.error, + startedAt: agentRuns.startedAt, + finishedAt: agentRuns.finishedAt, + }) + .from(agentRuns) + .where(eq(agentRuns.conversationId, conversationId)) + .orderBy(asc(agentRuns.startedAt)); + + return { ok: true, runs }; +} + +/** + * H5 : export du trail d'audit en JSON, groupé par message. Livrable + * « auditable / opposable » : qui (agents, rôles, modèles) a produit quoi, + * à quel coût (tokens) et latence, avec les erreurs éventuelles. + */ +export async function exportConversationAuditJson( + conversationId: string +): Promise<{ ok: true; json: string; filename: string } | { ok: false }> { + const userId = await requireUserId(); + const [conv] = await db + .select({ + id: conversations.id, + title: conversations.title, + createdAt: conversations.createdAt, + }) + .from(conversations) + .where( + and( + eq(conversations.id, conversationId), + eq(conversations.userId, userId) + ) + ) + .limit(1); + if (!conv) return { ok: false }; + + const trail = await getConversationAuditTrail(conversationId); + if (!trail.ok) return { ok: false }; + + const byMessage = new Map(); + for (const r of trail.runs) { + const key = r.messageId ?? "(non rattaché)"; + const list = byMessage.get(key) ?? []; + list.push(r); + byMessage.set(key, list); + } + + const payload = { + conversation: { + id: conv.id, + title: conv.title, + createdAt: new Date(conv.createdAt).toISOString(), + exportedAt: new Date().toISOString(), + }, + messages: Array.from(byMessage.entries()).map(([messageId, agents]) => ({ + messageId, + agents: agents.map((a) => ({ + role: a.role, + label: a.label, + modelId: a.modelId, + providerType: a.providerType, + inputTokens: a.inputTokens, + outputTokens: a.outputTokens, + latencyMs: a.latencyMs, + status: a.status, + error: a.error, + startedAt: a.startedAt ? new Date(a.startedAt).toISOString() : null, + finishedAt: a.finishedAt ? new Date(a.finishedAt).toISOString() : null, + })), + })), + }; + + const safeName = conv.title + .replace(/[^a-zA-Z0-9_\- ]+/g, "") + .replace(/\s+/g, "-") + .slice(0, 60) + .trim(); + return { + ok: true, + json: JSON.stringify(payload, null, 2), + filename: `${safeName || "conversation"}-audit.json`, + }; +} + export async function exportConversationMarkdown( id: string ): Promise<{ ok: true; markdown: string; filename: string } | { ok: false }> { diff --git a/src/app/(app)/chat/agent-event-badge.tsx b/src/app/(app)/chat/agent-event-badge.tsx index 9f78b3d..9db9b61 100644 --- a/src/app/(app)/chat/agent-event-badge.tsx +++ b/src/app/(app)/chat/agent-event-badge.tsx @@ -26,6 +26,8 @@ export interface AgentEventData { outputTokens?: number; preview?: string; error?: string; + /** Numéro de tour (mode council multi-tours). undefined sinon. */ + round?: number; /** * Numéro de la tentative en cours (1 = première, 2 = premier retry…). * Injecté côté chat-shell quand un `data-agent-retry` est reçu pour diff --git a/src/app/(app)/chat/chat-shell.tsx b/src/app/(app)/chat/chat-shell.tsx index d9bd1d6..6c6eec5 100644 --- a/src/app/(app)/chat/chat-shell.tsx +++ b/src/app/(app)/chat/chat-shell.tsx @@ -8,6 +8,7 @@ import { AgentEventBadge, dedupeAgentEvents, type AgentEventData, + type AgentRetryData, } from "./agent-event-badge"; import { LiveWorkflowPanel, @@ -16,6 +17,7 @@ import { import { AgentTheatre, buildAgentTurns, + OpenTheatreButton, type AgentTurn, } from "./agent-theatre"; import { ChatErrorBanner } from "./chat-error-banner"; @@ -71,6 +73,15 @@ import { } from "@/lib/providers/catalog"; import { MODEL_CATALOG, DEFAULT_MODEL } from "@/lib/providers/models"; import { computeCost, formatCost } from "@/lib/providers/pricing"; +import { estimateCalls, estimateRunCost } from "@/lib/orchestrator/cost-estimate"; +import { + LegifranceCitations, + PappersResults, + PappersCompany, + type LegifranceHitView, + type PappersResultView, + type PappersDetailsView, +} from "./citation-cards"; type KeyOption = { id: string; @@ -110,6 +121,10 @@ type PipelineOption = { description: string | null; isPreset: boolean; agentCount: number; + /** Mode d'exécution — pilote l'estimation du nombre d'appels LLM. */ + mode: "sequential" | "council" | "parallel"; + /** Tours de débat (mode council). null/1 sinon. */ + rounds: number | null; agents: PipelineAgentOption[]; }; @@ -146,6 +161,8 @@ type Props = { */ enabledModels?: EnabledModel[]; initialUsage: Usage; + /** Mapping slug → libellé des compétences activées (H4). */ + skillLabels?: Record; }; function toUIMessages(rows: Props["initialMessages"]): UIMessage[] { @@ -431,7 +448,7 @@ function EditedDocumentCard({
  • -

    +

    Avant

    @@ -439,7 +456,7 @@ function EditedDocumentCard({

    -

    +

    Après

    {edit.replace || (suppression)}

    @@ -578,6 +595,29 @@ function ToolPart({ ); } + // R3 : citations Légifrance / Pappers cliquables (au lieu de jeter les URLs + // sources dans une pill grise « Terminé »). + if (name === "legifrance_search" && !isPending) { + const d = unwrapToolResult<{ query: string; hits: LegifranceHitView[] }>( + output + ); + if (d?.hits?.length) return ; + } + + if (name === "pappers_search" && !isPending) { + const d = unwrapToolResult<{ + query: string; + total: number; + results: PappersResultView[]; + }>(output); + if (d?.results?.length) return ; + } + + if (name === "pappers_get" && !isPending) { + const d = unwrapToolResult(output); + if (d?.siren) return ; + } + return (
    {isPending ? ( @@ -624,7 +664,7 @@ function WorkflowPickerContent({
    -

    Workflows

    +

    Trames

    s.id === r.agentId); + if (ridx >= 0) { + baseStates[ridx] = { ...baseStates[ridx], retryAttempt: r.attempt }; + } + continue; + } if (part.type !== "data-agent-event") continue; const data = (part as { data?: AgentEventData }).data; if (!data?.agentId) continue; @@ -1434,6 +1486,32 @@ export function ChatShell({ return baseStates; }, [messages, selectedPipeline]); + // H10 : tour courant d'un conseil multi-tours, dérivé du max `round` vu + // dans les agent_start du dernier message assistant. Alimente le libellé + // « Tour N/M » du panneau live (null hors council ou à 1 tour). + const councilRound = useMemo<{ current: number; total: number } | null>(() => { + if (!selectedPipeline || selectedPipeline.mode !== "council") return null; + const total = selectedPipeline.rounds ?? 1; + if (total <= 1) return null; + const lastAssistant = [...messages] + .reverse() + .find((m) => m.role === "assistant"); + if (!lastAssistant?.parts) return null; + let current = 0; + for (const part of lastAssistant.parts) { + if (part.type !== "data-agent-event") continue; + const data = (part as { data?: AgentEventData }).data; + if ( + data?.type === "agent_start" && + typeof data.round === "number" && + data.round > current + ) { + current = data.round; + } + } + return current > 0 ? { current, total } : null; + }, [messages, selectedPipeline]); + // Dérivation pure : le panneau live s'affiche dès qu'au moins un agent // est actif/done/error dans la pipeline multi-agent en cours. Pas d'effet // à gérer — l'UI réagit naturellement aux nouveaux events. @@ -1458,19 +1536,24 @@ export function ChatShell({ // Theatre view : agrège les sorties intermédiaires (data-agent-output) // + le texte streamé du synthétiseur final. Reconstruit la timeline // chronologique de la délibération du conseil. - const [theatreOpen, setTheatreOpen] = useState(false); + // Théâtre : piloté par l'id du message à afficher (null = fermé). Permet de + // rouvrir la délibération de N'IMPORTE quel message multi-agents passé, pas + // seulement le dernier — avant, l'accès disparaissait avec le panneau live. + const [theatreMessageId, setTheatreMessageId] = useState(null); + const lastAssistantId = useMemo( + () => + [...messages].reverse().find((m) => m.role === "assistant")?.id ?? null, + [messages] + ); const theatreTurns: AgentTurn[] = useMemo(() => { - if (!selectedPipeline) return []; - const lastAssistant = [...messages] - .reverse() - .find((m) => m.role === "assistant"); - if (!lastAssistant?.parts) return []; + if (!selectedPipeline || !theatreMessageId) return []; + const msg = messages.find((m) => m.id === theatreMessageId); + if (!msg?.parts) return []; - // Collecte tous les events agents et le texte streamé final du - // dernier message assistant. + // Collecte les events agents + le texte final du message ciblé. const events: AgentEventData[] = []; let finalText = ""; - for (const part of lastAssistant.parts) { + for (const part of msg.parts) { if (part.type === "data-agent-event") { const d = (part as { data?: AgentEventData }).data; if (d) events.push(d); @@ -1479,14 +1562,49 @@ export function ChatShell({ if (text) finalText += text; } } - const isLastMessageStreaming = isBusy; + const isStreaming = isBusy && msg.id === messages[messages.length - 1]?.id; return buildAgentTurns( - lastAssistant.parts as { type: string; data?: unknown; text?: string }[], + msg.parts as { type: string; data?: unknown; text?: string }[], events, finalText || null, - isLastMessageStreaming + isStreaming ); - }, [messages, selectedPipeline, isBusy]); + }, [messages, selectedPipeline, isBusy, theatreMessageId]); + + // H4 : compétences détectées pour le dernier message assistant. Lues depuis + // la part data-skills-detected (persistée par H3a → survit au reload) et + // mappées en libellés lisibles. + const appliedSkills: string[] = useMemo(() => { + const lastAssistant = [...messages] + .reverse() + .find((m) => m.role === "assistant"); + if (!lastAssistant) return []; + for (const part of lastAssistant.parts) { + if (part.type === "data-skills-detected") { + const slugs = + (part as { data?: { slugs?: string[] } }).data?.slugs ?? []; + return slugs.map((s) => skillLabels[s] ?? s); + } + } + return []; + }, [messages, skillLabels]); + + // H8 : estimation AU POINT DE DÉPENSE. Le nombre d'appels LLM est exact + // (driver de coût d'un run multi-agents) ; le coût est une fourchette + // (tokens de sortie inconnus → suffixé « estimé »). Recalculé à chaque + // changement de modèle / pipeline / saisie, sans envoyer de requête. + // Placé après les useMemo qui dépendent de selectedPipeline pour ne pas + // casser la préservation de mémoïsation du React Compiler. + const estimatedCalls = estimateCalls({ + mode: selectedPipeline?.mode ?? "sequential", + agents: selectedPipeline?.agentCount ?? 1, + rounds: selectedPipeline?.rounds ?? 1, + }); + const estimatedRunCost = estimateRunCost({ + modelId, + calls: estimatedCalls, + promptChars: input.length, + }); // Auto-ouverture du DocPanel dès qu'un tool generate_document / // edit_document termine avec un document_id. On scanne les parts du @@ -1672,6 +1790,15 @@ export function ChatShell({ /> ))} + {/* Accès PERMANENT à la délibération de ce message (le + panneau live disparaît, pas ça). */} + {!isLiveMessage && ( +
    + setTheatreMessageId(m.id)} + /> +
    + )}
    )} {m.parts.map((part, i) => { @@ -1964,16 +2091,34 @@ export function ChatShell({
    )} + {appliedSkills.length > 0 && ( +
    + + Compétences appliquées + + {appliedSkills.map((label) => ( + + {label} + + ))} +
    + )} + {selectedPipeline && (
    setManuallyClosed(true)} onOpenTheatre={ - theatreTurns.length > 0 - ? () => setTheatreOpen(true) + lastAssistantId + ? () => setTheatreMessageId(lastAssistantId) : undefined } /> @@ -1982,8 +2127,10 @@ export function ChatShell({ {selectedPipeline && ( { + if (!o) setTheatreMessageId(null); + }} pipelineName={selectedPipeline.name} turns={theatreTurns} /> @@ -2124,6 +2271,16 @@ export function ChatShell({
    + {estimatedCalls > 1 && ( +

    + {selectedPipeline?.name} : ~{estimatedCalls} appels IA par question + {estimatedRunCost + ? ` · ~${formatCost(estimatedRunCost)} estimé` + : modelId + ? " · coût non tarifé pour ce modèle" + : ""} +

    + )}

    Louis n'est pas un avocat. Vérifiez le badge de souveraineté avant d'envoyer des données sensibles. diff --git a/src/app/(app)/chat/citation-cards.tsx b/src/app/(app)/chat/citation-cards.tsx new file mode 100644 index 0000000..d1d96d8 --- /dev/null +++ b/src/app/(app)/chat/citation-cards.tsx @@ -0,0 +1,219 @@ +import { + IconExternalLink, + IconScale, + IconBuilding, +} from "@tabler/icons-react"; + +/** + * Cartes de citation pour les sources juridiques externes (R3). Les outils + * legifrance_search / pappers_* renvoyaient jusqu'ici une simple pill grise + * « Terminé » qui jetait les URLs sources — alors que « Louis cite ses + * sources » est une promesse centrale. Ces cartes rendent chaque source + * cliquable (lien externe, nouvel onglet). + * + * Composants présentationnels purs (pas de hook) → pas de directive « use + * client » nécessaire ; intégrés au bundle client via chat-shell. + */ + +export type LegifranceHitView = { + id: string; + title: string; + url: string; + excerpt?: string; +}; + +export type PappersResultView = { + nom_entreprise: string; + siren: string; + ville?: string | null; + code_postal?: string | null; + forme_juridique?: string | null; +}; + +export type PappersDetailsView = { + nom_entreprise: string; + siren: string; + forme_juridique?: string | null; + capital?: number | null; + effectif?: string | null; + siege?: { + adresse_ligne_1?: string | null; + code_postal?: string | null; + ville?: string | null; + } | null; + dirigeants?: Array<{ nom?: string; prenom?: string; qualite?: string }>; +}; + +/** + * Défense en profondeur : ces URLs proviennent de réponses d'API externes + * (PISTE/Pappers) et alimentent un `href`. On n'autorise que http(s) — un + * schéma `javascript:`/`data:` ne doit jamais atteindre un href cliquable. + */ +function safeHttpUrl(u: string): string | null { + try { + const parsed = new URL(u); + return parsed.protocol === "https:" || parsed.protocol === "http:" + ? parsed.toString() + : null; + } catch { + return null; + } +} + +/** Rend un lien externe si l'URL est sûre, sinon un bloc non-cliquable + * (la citation reste visible). */ +function ExternalCard({ + href, + className, + children, +}: { + href: string | null; + className: string; + children: React.ReactNode; +}) { + if (href) { + return ( + + {children} + + ); + } + return

    {children}
    ; +} + +function CardShell({ + icon, + source, + count, + children, +}: { + icon: React.ReactNode; + source: string; + count?: string; + children: React.ReactNode; +}) { + return ( +
    +
    + {icon} + {source} + {count && {count}} +
    +
    {children}
    +
    + ); +} + +export function LegifranceCitations({ hits }: { hits: LegifranceHitView[] }) { + return ( + } + source="Légifrance" + count={`${hits.length} source${hits.length > 1 ? "s" : ""}`} + > + {hits.map((h, i) => ( + + + + {h.title} + + + + {h.excerpt && ( + + {h.excerpt} + + )} + + legifrance.gouv.fr + + + ))} + + ); +} + +function pappersUrl(siren: string): string { + return `https://www.pappers.fr/entreprise/${siren.replace(/\s/g, "")}`; +} + +export function PappersResults({ results }: { results: PappersResultView[] }) { + return ( + } + source="Pappers" + count={`${results.length} entreprise${results.length > 1 ? "s" : ""}`} + > + {results.map((r, i) => ( + + + + {r.nom_entreprise} + + + + + SIREN {r.siren} + {r.forme_juridique ? ` · ${r.forme_juridique}` : ""} + {r.ville ? ` · ${r.ville}` : ""} + + + ))} + + ); +} + +export function PappersCompany({ d }: { d: PappersDetailsView }) { + const dirigeants = (d.dirigeants ?? []).slice(0, 4); + return ( + } + source="Pappers — fiche entreprise" + > + + + + {d.nom_entreprise} + + + + + SIREN {d.siren} + {d.forme_juridique ? ` · ${d.forme_juridique}` : ""} + {typeof d.capital === "number" + ? ` · capital ${d.capital.toLocaleString("fr-FR")} €` + : ""} + {d.effectif ? ` · ${d.effectif}` : ""} + + {d.siege && (d.siege.adresse_ligne_1 || d.siege.ville) && ( + + {[d.siege.adresse_ligne_1, d.siege.code_postal, d.siege.ville] + .filter(Boolean) + .join(" ")} + + )} + {dirigeants.length > 0 && ( + + {dirigeants + .map((p) => + [p.prenom, p.nom].filter(Boolean).join(" ") + + (p.qualite ? ` (${p.qualite})` : "") + ) + .join(" · ")} + + )} + + + ); +} diff --git a/src/app/(app)/chat/composer-menu.tsx b/src/app/(app)/chat/composer-menu.tsx index 21b0410..ed20499 100644 --- a/src/app/(app)/chat/composer-menu.tsx +++ b/src/app/(app)/chat/composer-menu.tsx @@ -63,7 +63,7 @@ export function ComposerMenu({ @@ -89,7 +89,7 @@ export function ComposerMenu({ - Workflow + Trames {workflows.slice(0, 12).map((w) => ( @@ -104,14 +104,14 @@ export function ComposerMenu({ - Voir tous les workflows + Voir toutes les trames ) : ( - Workflow + Trames )} @@ -119,7 +119,7 @@ export function ComposerMenu({ <> - Bureau + Board diff --git a/src/app/(app)/chat/conversation-item.tsx b/src/app/(app)/chat/conversation-item.tsx index f3b04d2..69b14ad 100644 --- a/src/app/(app)/chat/conversation-item.tsx +++ b/src/app/(app)/chat/conversation-item.tsx @@ -32,6 +32,7 @@ import { deleteConversation, togglePinConversation, exportConversationMarkdown, + exportConversationAuditJson, } from "./actions"; import { moveConversationToProject } from "../projects/actions"; @@ -44,6 +45,19 @@ type Props = { projects?: { id: string; name: string }[]; }; +/** Déclenche le téléchargement d'un contenu texte généré côté serveur. */ +function downloadBlob(content: string, mime: string, filename: string) { + const blob = new Blob([content], { type: mime }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} + export function ConversationItem({ id, title, @@ -97,15 +111,15 @@ export function ConversationItem({ startTransition(async () => { const result = await exportConversationMarkdown(id); if (!result.ok) return; - const blob = new Blob([result.markdown], { type: "text/markdown" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = result.filename; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); + downloadBlob(result.markdown, "text/markdown", result.filename); + }); + } + + function handleExportAudit() { + startTransition(async () => { + const result = await exportConversationAuditJson(id); + if (!result.ok) return; + downloadBlob(result.json, "application/json", result.filename); }); } @@ -183,6 +197,10 @@ export function ConversationItem({ Exporter en Markdown + + + Exporter l'audit (JSON) + window.open(`/print/chat/${id}`, "_blank", "noopener,noreferrer") diff --git a/src/app/(app)/chat/live-workflow-panel.tsx b/src/app/(app)/chat/live-workflow-panel.tsx index 449ed73..2f7c473 100644 --- a/src/app/(app)/chat/live-workflow-panel.tsx +++ b/src/app/(app)/chat/live-workflow-panel.tsx @@ -13,12 +13,18 @@ export interface LiveAgentState { state: "idle" | "active" | "done" | "error"; latencyMs?: number; error?: string; + /** Tentative en cours si l'agent a été relancé (>1 = retry). */ + retryAttempt?: number; } interface LiveWorkflowPanelProps { open: boolean; pipelineName: string; agents: LiveAgentState[]; + /** Tour courant d'un conseil multi-tours (mode council). */ + round?: number; + /** Nombre total de tours du conseil. */ + totalRounds?: number; onClose?: () => void; onOpenTheatre?: () => void; } @@ -44,6 +50,8 @@ export function LiveWorkflowPanel({ open, pipelineName, agents, + round, + totalRounds, onClose, onOpenTheatre, }: LiveWorkflowPanelProps) { @@ -71,7 +79,8 @@ export function LiveWorkflowPanel({
    - Bureau en action + Board en action + {round && totalRounds ? ` · Tour ${round}/${totalRounds}` : ""}
    {pipelineName}
    @@ -163,7 +172,9 @@ function AgentStep({ agent }: { agent: LiveAgentState }) { - {verb}… + {(agent.retryAttempt ?? 0) > 1 + ? `nouvelle tentative ${agent.retryAttempt}…` + : `${verb}…`} )} diff --git a/src/app/(app)/chat/model-picker.tsx b/src/app/(app)/chat/model-picker.tsx index 9bf4afb..2485d03 100644 --- a/src/app/(app)/chat/model-picker.tsx +++ b/src/app/(app)/chat/model-picker.tsx @@ -119,7 +119,7 @@ export function ModelPicker({ {g.meta.label} {SOVEREIGNTY_LABEL[g.meta.sovereignty]} diff --git a/src/app/(app)/chat/page.tsx b/src/app/(app)/chat/page.tsx index 635b190..50f199d 100644 --- a/src/app/(app)/chat/page.tsx +++ b/src/app/(app)/chat/page.tsx @@ -15,6 +15,7 @@ import { } from "@/db/schema"; import { seedPresetsForUser } from "@/lib/orchestrator"; import { listEnabledModels } from "../settings/models/actions"; +import { getEnabledSkills } from "../settings/skills/actions"; import type { ProviderType } from "@/lib/providers/catalog"; import { ChatShell } from "./chat-shell"; @@ -117,6 +118,8 @@ export default async function ChatPage({ description: p.description, isPreset: p.isPreset, agentCount: agents.length, + mode: (p.mode ?? "sequential") as "sequential" | "council" | "parallel", + rounds: p.rounds ?? null, agents: agents.map((a) => ({ id: a.id, role: a.role, @@ -208,11 +211,18 @@ export default async function ChatPage({ totalOutputTokens = rows.reduce((n, r) => n + (r.outputTokens ?? 0), 0); } + // H4 : mapping slug → libellé des compétences activées, pour afficher + // « Compétence appliquée : X » quand le détecteur en déclenche une. + const skillLabels = Object.fromEntries( + (await getEnabledSkills(userId)).map((s) => [s.slug, s.name] as const) + ); + // key=currentId force le re-mount de ChatShell quand l'utilisateur change // de conversation via la sidebar (navigation soft Next sinon ne ré-init pas // le state interne de useChat). return ( 0 && ( <> - + {workflows.map((w) => ( 0; + const hasModel = enabledModels.length > 0; + const hasConnector = activeConnectors.length > 0; const hasContent = recentConvs.length > 0; + // H22 : rendre le plafond mensuel visible au membre (jusqu'ici réservé à + // l'admin). Même dépense que l'enforcement → pas de surprise « bloqué ». + const spentCentsMonth = Math.round((monthCost.EUR + monthCost.USD) * 100); + const quotaPct = + quotaCents != null && quotaCents > 0 + ? Math.min(100, Math.round((spentCentsMonth / quotaCents) * 100)) + : 0; + const quotaReached = quotaCents != null && spentCentsMonth >= quotaCents; + const monthHint = + quotaCents != null + ? `${formatCost({ amount: spentCentsMonth / 100, currency: "EUR" })} / ${formatCost({ amount: quotaCents / 100, currency: "EUR" })}${quotaReached ? " — plafond atteint" : quotaPct >= 80 ? ` — ${quotaPct} %` : ""}` + : "coût estimé"; + const monthHintTone: "default" | "warning" | "danger" = quotaReached + ? "danger" + : quotaCents != null && quotaPct >= 80 + ? "warning" + : "default"; + return (
    @@ -109,14 +141,22 @@ export default async function DashboardPage() {
  • - {!hasProvider && } + {(!hasProvider || !hasModel) && ( + + )} {/* Stats inline en grande typographie, pas une grille de cartes */}

    {c.title}

    - {timeAgo(c.updatedAt)} + {formatRelativeFr(c.updatedAt)} {c.projectId && " · dans un projet"}

    @@ -183,25 +223,105 @@ export default async function DashboardPage() { ); } -function SetupBanner() { +/** + * R9' : checklist de mise en route STATEFUL (et non plus basée sur le nombre + * de conversations). Reflète l'état réel : provider actif → modèle activé → + * connecteur (optionnel) → chat. Reste affichée tant que provider OU modèle + * manque ; disparaît une fois les deux présents. + */ +function ReadinessChecklist({ + hasProvider, + hasModel, + hasConnector, +}: { + hasProvider: boolean; + hasModel: boolean; + hasConnector: boolean; +}) { + const steps = [ + { + done: hasProvider, + label: "Ajouter une clé provider IA", + href: "/settings/providers", + hint: "Mistral, Scaleway, OVH, Albert, Anthropic, ou endpoint compatible.", + }, + { + done: hasModel, + label: "Activer au moins un modèle", + href: "/settings/models/library", + hint: "Sans modèle activé, le chat reste vide.", + }, + { + done: hasConnector, + label: "Brancher une source juridique", + href: "/settings/connectors", + hint: "Légifrance (PISTE), Pappers.", + optional: true, + }, + ]; + const ready = hasProvider && hasModel; + return ( -
    - -
    -

    - Ajoutez une première clé pour démarrer. -

    -

    - Sans clé provider active, le chat ne peut pas répondre. Configurez - Mistral, Scaleway, OVH, Albert, ou tout autre endpoint compatible. -

    - - Ouvrir Providers - - +
    +
    + +
    +

    Mise en route

    +

    + Quelques étapes pour rendre Louis opérationnel sur votre instance. +

    +
      + {steps.map((s, i) => ( +
    1. + {s.done ? ( + + + + ) : ( + + {i + 1} + + )} + + {s.label} + + {s.optional && !s.done && ( + + optionnel + + )} + {!s.done && ( + + — {s.hint} + + )} +
    2. + ))} +
    3. + + + + + Lancer une première conversation + +
    4. +
    +
    ); @@ -256,13 +376,21 @@ function Stat({ label, value, hint, + hintTone = "default", href, }: { label: string; value: string; hint?: string; + hintTone?: "default" | "warning" | "danger"; href?: string; }) { + const hintClass = + hintTone === "danger" + ? "text-destructive" + : hintTone === "warning" + ? "text-warning" + : "text-muted-foreground"; const inner = (
    @@ -272,7 +400,7 @@ function Stat({ {value} {hint && ( -
    {hint}
    +
    {hint}
    )}
    ); @@ -289,15 +417,3 @@ function Stat({ return
    {inner}
    ; } -function timeAgo(date: Date | string): string { - const d = typeof date === "string" ? new Date(date) : date; - const ms = Date.now() - d.getTime(); - const m = Math.floor(ms / 60_000); - if (m < 1) return "à l'instant"; - if (m < 60) return `il y a ${m} min`; - const h = Math.floor(m / 60); - if (h < 24) return `il y a ${h} h`; - const days = Math.floor(h / 24); - if (days < 30) return `il y a ${days} j`; - return d.toLocaleDateString("fr-FR"); -} diff --git a/src/app/(app)/documents/actions.ts b/src/app/(app)/documents/actions.ts index fc94948..7f9c8e6 100644 --- a/src/app/(app)/documents/actions.ts +++ b/src/app/(app)/documents/actions.ts @@ -1,13 +1,15 @@ "use server"; import { revalidatePath } from "next/cache"; -import { and, eq } from "drizzle-orm"; +import { and, eq, inArray, isNotNull } from "drizzle-orm"; import { z } from "zod"; import { auth } from "@/auth"; import { db } from "@/db"; import { documents, documentFolders } from "@/db/schema"; import { deleteObject } from "@/lib/storage"; import { recordAudit } from "@/lib/audit"; +import { reindexDocument, type ReindexResult } from "@/lib/rag/index-document"; +import { diffLines, collapseDiff, type DisplayOp } from "@/lib/diff/line-diff"; async function requireUserId(): Promise { const session = await auth(); @@ -44,6 +46,46 @@ export async function deleteDocument(id: string): Promise { revalidatePath("/chat"); } +/** R6 : réindexation RAG d'un document (recovery après ajout de clé Mistral + * ou échec d'embedding). Idempotent — remplace les chunks existants. */ +export async function reindexDocumentAction( + documentId: string +): Promise { + const userId = await requireUserId(); + const result = await reindexDocument(userId, documentId); + revalidatePath("/documents"); + return result; +} + +/** R6 : réindexe tous les documents de l'utilisateur (utile après avoir + * ajouté sa clé Mistral suite à des imports non indexés). */ +export async function reindexAllDocumentsAction(): Promise<{ + indexed: number; + failed: number; + noKey: boolean; +}> { + const userId = await requireUserId(); + const docs = await db + .select({ id: documents.id }) + .from(documents) + .where( + and(eq(documents.userId, userId), isNotNull(documents.extractedText)) + ); + let indexed = 0; + let failed = 0; + let noKey = false; + for (const d of docs) { + const r = await reindexDocument(userId, d.id); + if (r.ok) indexed += 1; + else { + failed += 1; + if (r.reason === "no_mistral_key") noKey = true; + } + } + revalidatePath("/documents"); + return { indexed, failed, noKey }; +} + const folderNameSchema = z.string().trim().min(1).max(80); export async function createFolder( @@ -137,3 +179,78 @@ export async function moveDocumentToFolder( revalidatePath("/documents"); return { ok: true }; } + +export type VersionDiffResult = + | { + ok: true; + ops: DisplayOp[]; + truncated: boolean; + older: { version: number; filename: string }; + newer: { version: number; filename: string }; + } + | { ok: false; error: string }; + +/** Borne dure du payload de diff renvoyé (lignes affichables, contexte inclus). */ +const MAX_DIFF_OPS = 4000; + +/** + * H19 — compare le texte extrait de deux versions d'un même document. + * Sécurité : les deux ids doivent appartenir à l'utilisateur ET à la même + * famille de versions (root = parentDocumentId ?? id). On replie les plages + * inchangées et on plafonne le nombre de lignes renvoyées. + */ +export async function getDocumentVersionDiff( + aId: string, + bId: string +): Promise { + const userId = await requireUserId(); + + const rows = await db + .select({ + id: documents.id, + version: documents.version, + filename: documents.filename, + parentDocumentId: documents.parentDocumentId, + extractedText: documents.extractedText, + }) + .from(documents) + .where(and(inArray(documents.id, [aId, bId]), eq(documents.userId, userId))); + + const a = rows.find((r) => r.id === aId); + const b = rows.find((r) => r.id === bId); + if (!a || !b) return { ok: false, error: "Version introuvable." }; + + const rootA = a.parentDocumentId ?? a.id; + const rootB = b.parentDocumentId ?? b.id; + if (rootA !== rootB) { + return { + ok: false, + error: "Ces documents n'appartiennent pas à la même famille de versions.", + }; + } + + if (a.extractedText == null || b.extractedText == null) { + return { + ok: false, + error: + "Le texte d'au moins une version n'a pas pu être extrait — comparaison impossible.", + }; + } + + // Toujours différ l'ancienne version vers la plus récente. + const [older, newer] = a.version <= b.version ? [a, b] : [b, a]; + const oldText = older.extractedText ?? ""; + const newText = newer.extractedText ?? ""; + + const { ops, truncated: dpTruncated } = diffLines(oldText, newText); + const collapsed = collapseDiff(ops); + const truncated = dpTruncated || collapsed.length > MAX_DIFF_OPS; + + return { + ok: true, + ops: truncated ? collapsed.slice(0, MAX_DIFF_OPS) : collapsed, + truncated, + older: { version: older.version, filename: older.filename }, + newer: { version: newer.version, filename: newer.filename }, + }; +} diff --git a/src/app/(app)/documents/document-row.tsx b/src/app/(app)/documents/document-row.tsx index ac7c41e..9aac79e 100644 --- a/src/app/(app)/documents/document-row.tsx +++ b/src/app/(app)/documents/document-row.tsx @@ -17,7 +17,10 @@ import { IconVersions, IconHistory, IconFolder, + IconRefresh, + IconDatabase, } from "@tabler/icons-react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { DropdownMenu, @@ -30,8 +33,13 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; +import { VersionDiffButton } from "./version-diff-dialog"; import type { Document, DocumentFolder } from "@/db/schema"; -import { deleteDocument, moveDocumentToFolder } from "./actions"; +import { + deleteDocument, + moveDocumentToFolder, + reindexDocumentAction, +} from "./actions"; import { moveDocumentToProject } from "../projects/actions"; type Props = { @@ -40,6 +48,10 @@ type Props = { folders?: DocumentFolder[]; /** Older revisions (v1, v2…) of the same family, oldest first. */ versions?: Document[]; + /** Nombre de chunks RAG indexés (transparence RAG). */ + chunkCount?: number; + /** L'utilisateur a-t-il une clé Mistral active (requise pour embedder) ? */ + hasMistralKey?: boolean; }; export function DocumentRow({ @@ -47,6 +59,8 @@ export function DocumentRow({ projects = [], folders = [], versions = [], + chunkCount = 0, + hasMistralKey = false, }: Props) { const router = useRouter(); const [pending, startTransition] = useTransition(); @@ -94,6 +108,28 @@ export function DocumentRow({ }); } + const hasText = + entry.extractionStatus === "ok" || entry.extractionStatus === "truncated"; + const indexed = chunkCount > 0; + + function reindex() { + startTransition(async () => { + const r = await reindexDocumentAction(entry.id); + if (r.ok) { + toast.success( + `Document indexé (${r.chunks} segment${r.chunks > 1 ? "s" : ""}).` + ); + } else if (r.reason === "no_mistral_key") { + toast.error("Aucune clé Mistral active — impossible d'indexer."); + } else if (r.reason === "no_text") { + toast.error("Aucun texte exploitable à indexer."); + } else { + toast.error("Échec de l'indexation."); + } + router.refresh(); + }); + } + const hasHistory = versions.length > 0; return ( @@ -122,6 +158,31 @@ export function DocumentRow({ extraction échouée )} + {entry.extractionStatus !== "failed" && + hasText && + (indexed ? ( + + + indexé · {chunkCount} + + ) : !hasMistralKey ? ( + + clé Mistral manquante + + ) : ( + + non indexé + + ))} {entry.projectId && ( @@ -161,6 +222,12 @@ export function DocumentRow({ Uploader nouvelle version + {hasText && ( + reindex()}> + + {indexed ? "Réindexer" : "Indexer pour la recherche"} + + )} {hasHistory && ( setHistoryOpen((v) => !v)}> @@ -318,6 +385,14 @@ export function DocumentRow({ {new Date(v.createdAt).toLocaleDateString("fr-FR")} + + + ))} diff --git a/src/app/(app)/documents/documents-dropzone.tsx b/src/app/(app)/documents/documents-dropzone.tsx index 615515a..b82c6c6 100644 --- a/src/app/(app)/documents/documents-dropzone.tsx +++ b/src/app/(app)/documents/documents-dropzone.tsx @@ -46,6 +46,16 @@ export function DocumentsDropzone({ return ( { + const types = rejected.filter((r) => r.reason === "type").length; + const sizes = rejected.filter((r) => r.reason === "size").length; + const parts: string[] = []; + if (types) parts.push(`${types} de type non supporté`); + if (sizes) parts.push(`${sizes} > 25 Mo`); + setError( + `${rejected.length} fichier(s) ignoré(s) : ${parts.join(" · ")}.` + ); + }} overlayLabel="Déposez pour importer dans ce dossier" overlayHint="PDF, DOCX ou texte — 25 Mo max par fichier" > diff --git a/src/app/(app)/documents/folder-row.tsx b/src/app/(app)/documents/folder-row.tsx index ca9d6b1..7620678 100644 --- a/src/app/(app)/documents/folder-row.tsx +++ b/src/app/(app)/documents/folder-row.tsx @@ -22,7 +22,14 @@ import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import type { DocumentFolder } from "@/db/schema"; import { deleteFolder, renameFolder } from "./actions"; -export function FolderRow({ folder }: { folder: DocumentFolder }) { +export function FolderRow({ + folder, + subfolderCount = 0, +}: { + folder: DocumentFolder; + /** Nombre de sous-dossiers (récursif) supprimés en cascade (H20). */ + subfolderCount?: number; +}) { const router = useRouter(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState(folder.name); @@ -140,7 +147,16 @@ export function FolderRow({ folder }: { folder: DocumentFolder }) { title="Supprimer ce dossier ?" description={ <> - « {folder.name} » sera supprimé. Les documents qu'il contient + « {folder.name} » sera supprimé + {subfolderCount > 0 && ( + <> + {" "} + ainsi que ses {subfolderCount} sous-dossier + {subfolderCount > 1 ? "s" : ""} + + )} + . Tous les documents qu'il contient + {subfolderCount > 0 ? " (y compris ceux des sous-dossiers)" : ""}{" "} remonteront à la racine — ils ne sont pas perdus. } diff --git a/src/app/(app)/documents/page.tsx b/src/app/(app)/documents/page.tsx index d16d848..9011391 100644 --- a/src/app/(app)/documents/page.tsx +++ b/src/app/(app)/documents/page.tsx @@ -1,18 +1,21 @@ import { redirect } from "next/navigation"; import Link from "next/link"; -import { asc, desc, eq, and } from "drizzle-orm"; +import { asc, desc, eq, and, sql } from "drizzle-orm"; import { IconFolder, IconChevronRight } from "@tabler/icons-react"; import { auth } from "@/auth"; import { db } from "@/db"; import { documents, + documentChunks, documentFolders, + providerKeys, projects, type Document, type DocumentFolder, } from "@/db/schema"; import { Badge } from "@/components/ui/badge"; import { UploadButton } from "./upload-button"; +import { ReindexAllButton } from "./reindex-all-button"; import { DocumentRow } from "./document-row"; import { FolderRow } from "./folder-row"; import { NewFolderButton } from "./new-folder-button"; @@ -35,7 +38,14 @@ export default async function DocumentsPage({ // Charge tout — volume documentaire d'un cabinet reste modeste pour l'usage // interne (quelques milliers de docs max). On filtre côté JS pour pouvoir // construire en parallèle la breadcrumb et les sous-dossiers. - const [allDocs, allFolders, projectList, currentFolder] = await Promise.all([ + const [ + allDocs, + allFolders, + projectList, + currentFolder, + chunkCountRows, + mistralKeys, + ] = await Promise.all([ db .select() .from(documents) @@ -64,8 +74,37 @@ export default async function DocumentsPage({ .limit(1) .then((r) => r[0] ?? null) : Promise.resolve(null), + // R6 : nombre de chunks indexés par document (transparence RAG). + db + .select({ + documentId: documentChunks.documentId, + n: sql`count(*)::int`, + }) + .from(documentChunks) + .innerJoin(documents, eq(documents.id, documentChunks.documentId)) + .where(eq(documents.userId, userId)) + .groupBy(documentChunks.documentId), + db + .select({ id: providerKeys.id }) + .from(providerKeys) + .where( + and( + eq(providerKeys.userId, userId), + eq(providerKeys.type, "mistral"), + eq(providerKeys.isActive, true) + ) + ) + .limit(1), ]); + // Transparence RAG : chunks par doc + présence d'une clé Mistral active + // (requise pour embedder). Permet d'afficher « indexé (N) / non indexé / + // clé Mistral manquante » par document. + const chunkCountByDoc = new Map( + chunkCountRows.map((r) => [r.documentId, r.n]) + ); + const hasMistralKey = mistralKeys.length > 0; + // Construit la breadcrumb en remontant via parentFolderId. const folderById = new Map( allFolders.map((f) => [f.id, f]) @@ -137,6 +176,7 @@ export default async function DocumentsPage({

    + {totalDocs > 0 && }
    @@ -193,7 +233,10 @@ export default async function DocumentsPage({ > {subFolders.map((f) => (
  • - +
  • ))} {familyViews.map((fv) => ( @@ -203,6 +246,8 @@ export default async function DocumentsPage({ projects={projectList} folders={allFolders} versions={fv.older} + chunkCount={chunkCountByDoc.get(fv.latest.id) ?? 0} + hasMistralKey={hasMistralKey} /> ))} @@ -215,6 +260,17 @@ export default async function DocumentsPage({ ); } +/** Nombre de sous-dossiers (récursif) d'un dossier — pour avertir de la + * suppression en cascade (H20). */ +function countDescendantFolders( + folderId: string, + all: DocumentFolder[] +): number { + return all + .filter((f) => f.parentFolderId === folderId) + .reduce((n, c) => n + 1 + countDescendantFolders(c.id, all), 0); +} + function EmptyState({ isRoot }: { isRoot: boolean }) { return (
    @@ -236,8 +292,9 @@ function FormatsNote() {

    Formats acceptés

    PDF, DOCX et texte brut. Limite : 25 Mo par fichier, ~500 000 - caractères extraits. Au-delà, l'extraction est tronquée — le - RAG (chunking + embeddings) arrive en v0.3. + caractères extraits (au-delà, l'extraction est tronquée). Chaque + document est indexé pour la recherche sémantique (RAG) dès qu'une + clé Mistral active est configurée.

    ); diff --git a/src/app/(app)/documents/reindex-all-button.tsx b/src/app/(app)/documents/reindex-all-button.tsx new file mode 100644 index 0000000..695e88f --- /dev/null +++ b/src/app/(app)/documents/reindex-all-button.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { IconRefresh } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { reindexAllDocumentsAction } from "./actions"; + +/** + * Réindexe tous les documents de l'utilisateur — recovery typique après + * l'ajout d'une clé Mistral suite à des imports non indexés. + */ +export function ReindexAllButton() { + const router = useRouter(); + const [pending, startTransition] = useTransition(); + + function run() { + startTransition(async () => { + const r = await reindexAllDocumentsAction(); + if (r.noKey && r.indexed === 0) { + toast.error("Aucune clé Mistral active — impossible d'indexer."); + } else if (r.failed > 0) { + toast.warning( + `${r.indexed} document(s) indexé(s), ${r.failed} en échec.` + ); + } else { + toast.success(`${r.indexed} document(s) réindexé(s).`); + } + router.refresh(); + }); + } + + return ( + + ); +} diff --git a/src/app/(app)/documents/upload-button.tsx b/src/app/(app)/documents/upload-button.tsx index 64e5bb2..320fe7f 100644 --- a/src/app/(app)/documents/upload-button.tsx +++ b/src/app/(app)/documents/upload-button.tsx @@ -5,6 +5,7 @@ import { useRouter } from "next/navigation"; import { IconUpload } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; +import { uploadDocument } from "@/components/dropzone"; export function UploadButton({ folderId = null, @@ -17,30 +18,28 @@ export function UploadButton({ const [pending, startTransition] = useTransition(); function onChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (!file) return; + const files = Array.from(e.target.files ?? []); + if (files.length === 0) return; setError(null); - const formData = new FormData(); - formData.append("file", file); - if (folderId) formData.append("folder", folderId); - + // H16 : import multi-fichiers (l'input porte `multiple`). Upload + // séquentiel — l'API fait extraction + embedding synchrones, le + // parallélisme saturerait le provider d'embedding. startTransition(async () => { - try { - const res = await fetch("/api/documents/upload", { - method: "POST", - body: formData, - }); - if (!res.ok) { - const msg = await res.text(); - setError(msg || "Erreur lors de l'envoi."); - return; - } - if (fileRef.current) fileRef.current.value = ""; - router.refresh(); - } catch (err) { - setError(err instanceof Error ? err.message : "Erreur réseau."); + let failed = 0; + for (const file of files) { + const r = await uploadDocument(file, { folderId }); + if (!r.ok) failed += 1; + } + if (fileRef.current) fileRef.current.value = ""; + if (failed > 0) { + setError( + `${failed} fichier${failed > 1 ? "s" : ""} sur ${files.length} n'${ + failed > 1 ? "ont" : "a" + } pas pu être importé${failed > 1 ? "s" : ""}.` + ); } + router.refresh(); }); } @@ -56,6 +55,7 @@ export function UploadButton({ (null); + + function load() { + setOpen(true); + if (result) return; + startTransition(async () => { + setResult(await getDocumentVersionDiff(olderId, currentId)); + }); + } + + return ( + <> + + + + + + + + Comparaison v{olderVersion} → v{currentVersion} + + + Différences sur le texte extrait. Les passages identiques sont + repliés. Ceci ne remplace pas une relecture du document final. + + + + {pending && ( +
    + + Calcul des différences… +
    + )} + + {!pending && result && !result.ok && ( +

    + {result.error} +

    + )} + + {!pending && result && result.ok && ( + + )} +
    +
    + + ); +} + +function DiffView({ + ops, + truncated, + olderVersion, + newerVersion, +}: { + ops: DisplayOp[]; + truncated: boolean; + olderVersion: number; + newerVersion: number; +}) { + let added = 0; + let removed = 0; + for (const op of ops) { + if (op.type === "add") added++; + else if (op.type === "del") removed++; + } + + if (added === 0 && removed === 0) { + return ( +

    + Aucune différence textuelle entre la v{olderVersion} et la v + {newerVersion}. +

    + ); + } + + return ( +
    +
    + + + {added} ajoutée{added > 1 ? "s" : ""} + + + − {removed} supprimée{removed > 1 ? "s" : ""} + +
    + + {truncated && ( +

    + Comparaison volumineuse : seul le début des différences est affiché. +

    + )} + +
    + {ops.map((op, i) => { + if (op.type === "gap") { + return ( +
    + ··· {op.count} ligne{op.count > 1 ? "s" : ""} inchangée + {op.count > 1 ? "s" : ""} ··· +
    + ); + } + const cls = + op.type === "add" + ? "bg-success/10 text-foreground" + : op.type === "del" + ? "bg-destructive/10 text-foreground" + : "text-muted-foreground"; + const marker = + op.type === "add" ? "+" : op.type === "del" ? "−" : " "; + return ( +
    + + {marker} + + + {op.text || " "} + +
    + ); + })} +
    +
    + ); +} diff --git a/src/app/(app)/error.tsx b/src/app/(app)/error.tsx new file mode 100644 index 0000000..a6253d5 --- /dev/null +++ b/src/app/(app)/error.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { useEffect } from "react"; +import { log } from "@/lib/log"; + +/** + * Error boundary du segment (app) : remplace l'écran d'erreur brut Next par + * une page brandée avec récupération (`reset`). Ne fuit pas la stack à + * l'utilisateur (produit confidentialité-sensible). + */ +export default function Error({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + log.error("app", "render error boundary", { + message: error.message, + digest: error.digest, + }); + }, [error]); + + return ( +
    +

    + Une erreur est survenue +

    +

    + Quelque chose s'est mal passé de notre côté. Vous pouvez réessayer ; + si le problème persiste, contactez l'administrateur de votre + cabinet. +

    +
    + + + Tableau de bord + +
    +
    + ); +} diff --git a/src/app/(app)/loading.tsx b/src/app/(app)/loading.tsx new file mode 100644 index 0000000..484c809 --- /dev/null +++ b/src/app/(app)/loading.tsx @@ -0,0 +1,25 @@ +/** + * Skeleton de segment (app) — évite l'écran figé pendant le chargement des + * Server Components multi-requêtes (dashboard, admin, usage…). Respecte + * prefers-reduced-motion. + */ +export default function Loading() { + return ( +
    + Chargement… +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + ); +} diff --git a/src/app/(app)/not-found.tsx b/src/app/(app)/not-found.tsx new file mode 100644 index 0000000..89a9481 --- /dev/null +++ b/src/app/(app)/not-found.tsx @@ -0,0 +1,24 @@ +import Link from "next/link"; + +export default function NotFound() { + return ( +
    +

    + 404 +

    +

    + Page introuvable +

    +

    + Cette page n'existe pas, a été déplacée, ou ne vous est pas + accessible. +

    + + Retour au tableau de bord + +
    + ); +} diff --git a/src/app/(app)/projects/[id]/page.tsx b/src/app/(app)/projects/[id]/page.tsx index 747c856..b5a9014 100644 --- a/src/app/(app)/projects/[id]/page.tsx +++ b/src/app/(app)/projects/[id]/page.tsx @@ -1,17 +1,17 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { IconArrowLeft, - IconFolder, IconMessageCircle, - IconFileText, IconPlus, } from "@tabler/icons-react"; import { auth } from "@/auth"; import { db } from "@/db"; import { conversations, documents, projects } from "@/db/schema"; +import { getProjectScope } from "@/lib/projects/scope"; import { ProjectActions } from "./project-actions"; +import { ProjectDocuments } from "./project-documents"; type Params = { id: string }; @@ -34,6 +34,8 @@ export default async function ProjectDetailPage({ if (!project) notFound(); + const scope = await getProjectScope(userId, id); + const [convList, docList] = await Promise.all([ db .select({ @@ -49,19 +51,24 @@ export default async function ProjectDetailPage({ ) ) .orderBy(desc(conversations.updatedAt)), - db - .select({ - id: documents.id, - filename: documents.filename, - contentType: documents.contentType, - sizeBytes: documents.sizeBytes, - createdAt: documents.createdAt, - }) - .from(documents) - .where( - and(eq(documents.projectId, id), eq(documents.userId, userId)) - ) - .orderBy(desc(documents.createdAt)), + scope.documentIds.length > 0 + ? db + .select({ + id: documents.id, + filename: documents.filename, + contentType: documents.contentType, + sizeBytes: documents.sizeBytes, + createdAt: documents.createdAt, + }) + .from(documents) + .where( + and( + eq(documents.userId, userId), + inArray(documents.id, scope.documentIds) + ) + ) + .orderBy(desc(documents.createdAt)) + : Promise.resolve([]), ]); return ( @@ -75,20 +82,15 @@ export default async function ProjectDetailPage({
    -
    -
    - -
    -
    -

    - {project.name} -

    - {project.description && ( -

    - {project.description} -

    - )} -
    +
    +

    + {project.name} +

    + {project.description && ( +

    + {project.description} +

    + )}
    {convList.length === 0 ? ( -
    - Aucune conversation pour l'instant. +
    + Démarrez une conversation rattachée à ce dossier pour en garder + l'historique au même endroit.
    ) : (
    @@ -128,11 +131,14 @@ export default async function ProjectDetailPage({ href={`/chat?id=${c.id}`} className="flex items-center gap-3 px-4 py-3 hover:bg-accent/40 transition-colors" > - + {c.title} - + {new Date(c.updatedAt).toLocaleDateString("fr-FR")} @@ -142,46 +148,7 @@ export default async function ProjectDetailPage({ {/* Documents */} -
    -
    -

    - - Documents - - ({docList.length}) - -

    - - - Importer un document - -
    - {docList.length === 0 ? ( -
    - Aucun document rattaché à ce projet. -
    - ) : ( -
    - {docList.map((d) => ( -
    - - - {d.filename} - - - {new Date(d.createdAt).toLocaleDateString("fr-FR")} - -
    - ))} -
    - )} -
    +
    ); diff --git a/src/app/(app)/projects/[id]/project-actions.tsx b/src/app/(app)/projects/[id]/project-actions.tsx index 0d66ef2..d600f46 100644 --- a/src/app/(app)/projects/[id]/project-actions.tsx +++ b/src/app/(app)/projects/[id]/project-actions.tsx @@ -25,7 +25,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; -import { deleteProject, renameProject } from "../actions"; +import { deleteProject, updateProject } from "../actions"; type Props = { id: string; @@ -33,18 +33,20 @@ type Props = { description: string | null; }; -export function ProjectActions({ id, name }: Props) { - const [renameOpen, setRenameOpen] = useState(false); +export function ProjectActions({ id, name, description }: Props) { + const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); - const [draft, setDraft] = useState(name); + const [nameDraft, setNameDraft] = useState(name); + const [descDraft, setDescDraft] = useState(description ?? ""); const [pending, startTransition] = useTransition(); - function handleRename(formData: FormData) { + function handleEdit(formData: FormData) { const next = (formData.get("name") as string)?.trim(); if (!next) return; + const nextDesc = (formData.get("description") as string) ?? ""; startTransition(async () => { - await renameProject(id, next); - setRenameOpen(false); + await updateProject(id, next, nextDesc); + setEditOpen(false); }); } @@ -65,12 +67,13 @@ export function ProjectActions({ id, name }: Props) { { - setDraft(name); - setRenameOpen(true); + setNameDraft(name); + setDescDraft(description ?? ""); + setEditOpen(true); }} > - Renommer + Modifier - + - Renommer le projet + Modifier le projet - Le nouveau nom sera visible partout (sidebar, liste des projets, - etc.). + Le nom est visible partout (sidebar, liste des projets, etc.). -
    +
    - + setDraft(e.target.value)} + value={nameDraft} + onChange={(e) => setNameDraft(e.target.value)} required maxLength={80} autoFocus />
    +
    + + setDescDraft(e.target.value)} + maxLength={500} + placeholder="Note interne, n° dossier, partie adverse…" + /> +
    diff --git a/src/app/(app)/projects/[id]/project-documents.tsx b/src/app/(app)/projects/[id]/project-documents.tsx new file mode 100644 index 0000000..c7959c0 --- /dev/null +++ b/src/app/(app)/projects/[id]/project-documents.tsx @@ -0,0 +1,165 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + IconFileText, + IconUpload, + IconAlertTriangle, + IconX, +} from "@tabler/icons-react"; +import { Dropzone, uploadDocument } from "@/components/dropzone"; +import { Spinner } from "@/components/ui/spinner"; + +type Doc = { + id: string; + filename: string; + createdAt: Date | string; +}; + +const ACCEPT = + ".pdf,.docx,.txt,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain"; + +export function ProjectDocuments({ + folderId, + docs, +}: { + folderId: string | null; + docs: Doc[]; +}) { + const router = useRouter(); + const fileRef = useRef(null); + const [uploadingCount, setUploadingCount] = useState(0); + const [error, setError] = useState(null); + + const upload = useCallback( + async (files: File[]) => { + if (files.length === 0) return; + setError(null); + setUploadingCount((n) => n + files.length); + let anySuccess = false; + for (const file of files) { + const result = await uploadDocument(file, { folderId }); + if (result.ok) anySuccess = true; + else setError(`${file.name} — ${result.error}`); + setUploadingCount((n) => Math.max(0, n - 1)); + } + if (anySuccess) router.refresh(); + }, + [folderId, router] + ); + + function onPick(e: React.ChangeEvent) { + const files = Array.from(e.target.files ?? []); + if (fileRef.current) fileRef.current.value = ""; + upload(files); + } + + const busy = uploadingCount > 0; + + return ( +
    +
    +

    + + Documents + + ({docs.length}) + +

    + +
    + + + + {error && ( +
    + + {error} + +
    + )} + + + {docs.length === 0 ? ( +
    + Glissez vos pièces ici, ou importez-les — elles seront rattachées à + ce dossier. +
    + ) : ( + + )} + {busy && ( +
    + + Téléversement de {uploadingCount} fichier + {uploadingCount > 1 ? "s" : ""}… +
    + )} +
    +
    + ); +} diff --git a/src/app/(app)/projects/actions.ts b/src/app/(app)/projects/actions.ts index 88c8292..89a4811 100644 --- a/src/app/(app)/projects/actions.ts +++ b/src/app/(app)/projects/actions.ts @@ -6,7 +6,12 @@ import { and, eq } from "drizzle-orm"; import { z } from "zod"; import { auth } from "@/auth"; import { db } from "@/db"; -import { projects, conversations, documents } from "@/db/schema"; +import { + projects, + conversations, + documents, + documentFolders, +} from "@/db/schema"; const createSchema = z.object({ name: z.string().trim().min(1).max(80), @@ -32,7 +37,53 @@ export async function createProject( description: formData.get("description") ?? undefined, }); - if (!parsed.success) return { ok: false, error: "Champs invalides." }; + if (!parsed.success) { + const field = parsed.error.issues[0]?.path[0]; + return { + ok: false, + error: + field === "description" + ? "La description ne peut pas dépasser 500 caractères." + : "Le nom du projet est requis (80 caractères max).", + }; + } + + // Emplacement de stockage : soit un dossier existant (vérifié), soit un + // nouveau dossier créé à la racine. À défaut on crée un dossier au nom du + // projet — un projet a toujours un dossier-racine (modèle dossier = projet). + const folderMode = formData.get("folderMode"); + let folderId: string | null = null; + + if (folderMode === "existing") { + const existingRaw = formData.get("folderId"); + if (typeof existingRaw === "string" && existingRaw.length > 0) { + const [folder] = await db + .select({ id: documentFolders.id }) + .from(documentFolders) + .where( + and( + eq(documentFolders.id, existingRaw), + eq(documentFolders.userId, userId) + ) + ) + .limit(1); + if (!folder) { + return { ok: false, error: "Dossier de stockage introuvable." }; + } + folderId = folder.id; + } + } + + if (!folderId) { + const nameRaw = formData.get("folderName"); + const folderName = + (typeof nameRaw === "string" && nameRaw.trim()) || parsed.data.name; + const [folder] = await db + .insert(documentFolders) + .values({ userId, name: folderName.slice(0, 80), parentFolderId: null }) + .returning({ id: documentFolders.id }); + folderId = folder.id; + } const [row] = await db .insert(projects) @@ -40,21 +91,31 @@ export async function createProject( userId, name: parsed.data.name, description: parsed.data.description || null, + folderId, }) .returning({ id: projects.id }); revalidatePath("/projects"); + revalidatePath("/documents"); revalidatePath("/chat"); return { ok: true, id: row.id }; } -export async function renameProject(id: string, name: string): Promise { +export async function updateProject( + id: string, + name: string, + description: string | null +): Promise { const userId = await requireUserId(); const trimmed = name.trim(); if (!trimmed) return; await db .update(projects) - .set({ name: trimmed, updatedAt: new Date() }) + .set({ + name: trimmed.slice(0, 80), + description: description?.trim().slice(0, 500) || null, + updatedAt: new Date(), + }) .where(and(eq(projects.id, id), eq(projects.userId, userId))); revalidatePath("/projects"); revalidatePath(`/projects/${id}`); @@ -94,12 +155,28 @@ export async function moveDocumentToProject( projectId: string | null ): Promise { const userId = await requireUserId(); + + // H18 : le périmètre RAG d'un projet est défini par son DOSSIER + // (lib/projects/scope ignore projectId). On déplace donc RÉELLEMENT le + // document dans le dossier du projet — sinon le badge « projet » mentirait + // (le doc ne serait pas vu par search_documents en contexte projet). + // projectId reste écrit (miroir d'affichage du badge). En retirant d'un + // projet (projectId=null), on remonte le doc à la racine. + let folderId: string | null = null; + if (projectId) { + const [proj] = await db + .select({ folderId: projects.folderId }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.userId, userId))) + .limit(1); + if (!proj) return; // projet introuvable / pas le propriétaire → no-op + folderId = proj.folderId; + } + await db .update(documents) - .set({ projectId }) - .where( - and(eq(documents.id, documentId), eq(documents.userId, userId)) - ); + .set({ projectId, folderId }) + .where(and(eq(documents.id, documentId), eq(documents.userId, userId))); revalidatePath("/documents"); revalidatePath("/projects"); if (projectId) revalidatePath(`/projects/${projectId}`); diff --git a/src/app/(app)/projects/add-project-dialog.tsx b/src/app/(app)/projects/add-project-dialog.tsx index 8e14899..d6c0675 100644 --- a/src/app/(app)/projects/add-project-dialog.tsx +++ b/src/app/(app)/projects/add-project-dialog.tsx @@ -1,8 +1,8 @@ "use client"; -import { useState, useTransition } from "react"; +import { Fragment, useMemo, useState, useTransition } from "react"; import { useRouter } from "next/navigation"; -import { IconPlus } from "@tabler/icons-react"; +import { IconPlus, IconFolder } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; @@ -18,18 +18,34 @@ import { import { Alert, AlertDescription } from "@/components/ui/alert"; import { createProject } from "./actions"; -export function AddProjectDialog() { +export type FolderNode = { + id: string; + name: string; + parentFolderId: string | null; +}; + +export function AddProjectDialog({ folders }: { folders: FolderNode[] }) { const router = useRouter(); const [open, setOpen] = useState(false); const [error, setError] = useState(null); const [pending, startTransition] = useTransition(); + const hasFolders = folders.length > 0; + const [mode, setMode] = useState<"new" | "existing">("new"); + const [selectedFolderId, setSelectedFolderId] = useState(null); + function handleSubmit(formData: FormData) { setError(null); + if (mode === "existing" && !selectedFolderId) { + setError("Choisissez un dossier existant ou créez-en un nouveau."); + return; + } startTransition(async () => { const result = await createProject(null, formData); if (result.ok) { setOpen(false); + setMode("new"); + setSelectedFolderId(null); if (result.id) router.push(`/projects/${result.id}`); } else { setError(result.error); @@ -49,8 +65,8 @@ export function AddProjectDialog() { Nouveau projet - Donnez un nom à votre projet. Vous pourrez y rattacher - conversations et documents par la suite. + Un projet regroupe les conversations et documents d'un dossier. + Choisissez où ses pièces seront stockées. @@ -81,6 +97,64 @@ export function AddProjectDialog() { />
    +
    + +

    + Les documents du projet — et ce que Louis prendra en compte en + RAG — sont ceux rangés dans ce dossier et ses sous-dossiers. +

    + + + + {hasFolders && ( +
    + + +
    + )} + + {mode === "new" ? ( + + ) : ( + <> + + + + )} +
    + {error && ( {error} @@ -104,3 +178,62 @@ export function AddProjectDialog() { ); } + +function FolderTree({ + folders, + selectedId, + onSelect, +}: { + folders: FolderNode[]; + selectedId: string | null; + onSelect: (id: string) => void; +}) { + const childrenByParent = useMemo(() => { + const map = new Map(); + for (const f of folders) { + const list = map.get(f.parentFolderId) ?? []; + list.push(f); + map.set(f.parentFolderId, list); + } + for (const list of map.values()) + list.sort((a, b) => a.name.localeCompare(b.name)); + return map; + }, [folders]); + + function renderLevel( + parentId: string | null, + depth: number + ): React.ReactNode { + const nodes = childrenByParent.get(parentId) ?? []; + return nodes.map((f) => ( + + + {renderLevel(f.id, depth + 1)} + + )); + } + + return ( +
    + {renderLevel(null, 0)} +
    + ); +} diff --git a/src/app/(app)/projects/page.tsx b/src/app/(app)/projects/page.tsx index 3cb9296..d809b20 100644 --- a/src/app/(app)/projects/page.tsx +++ b/src/app/(app)/projects/page.tsx @@ -8,7 +8,9 @@ import { } from "@tabler/icons-react"; import { auth } from "@/auth"; import { db } from "@/db"; -import { conversations, documents, projects } from "@/db/schema"; +import { conversations, documentFolders, projects } from "@/db/schema"; +import { getProjectDocCounts } from "@/lib/projects/scope"; +import { ModuleHelp } from "@/components/module-help"; import { AddProjectDialog } from "./add-project-dialog"; export default async function ProjectsPage() { @@ -19,25 +21,35 @@ export default async function ProjectsPage() { if (!session?.user) redirect("/login"); const userId = session.user.id; - const list = await db - .select({ - id: projects.id, - name: projects.name, - description: projects.description, - createdAt: projects.createdAt, - updatedAt: projects.updatedAt, - convCount: sql`( - SELECT COUNT(*) FROM ${conversations} - WHERE ${conversations.projectId} = ${projects.id} - )::int`, - docCount: sql`( - SELECT COUNT(*) FROM ${documents} - WHERE ${documents.projectId} = ${projects.id} - )::int`, - }) - .from(projects) - .where(eq(projects.userId, userId)) - .orderBy(desc(projects.updatedAt)); + // docCount via le sous-arbre du dossier-racine de chaque projet (modèle + // dossier = projet), pas via documents.projectId qui n'est plus la source + // de vérité. + const [list, docCounts, folders] = await Promise.all([ + db + .select({ + id: projects.id, + name: projects.name, + description: projects.description, + createdAt: projects.createdAt, + updatedAt: projects.updatedAt, + convCount: sql`( + SELECT COUNT(*) FROM ${conversations} + WHERE ${conversations.projectId} = ${projects.id} + )::int`, + }) + .from(projects) + .where(eq(projects.userId, userId)) + .orderBy(desc(projects.updatedAt)), + getProjectDocCounts(userId), + db + .select({ + id: documentFolders.id, + name: documentFolders.name, + parentFolderId: documentFolders.parentFolderId, + }) + .from(documentFolders) + .where(eq(documentFolders.userId, userId)), + ]); return (
    @@ -46,15 +58,20 @@ export default async function ProjectsPage() {

    Dossiers clients · matières · affaires

    -

    - Projets. -

    +
    +

    Projets.

    + + Un projet regroupe les conversations et documents d'un + dossier client, et restreint le raisonnement de l'IA à ce + seul périmètre. + +

    Regroupez conversations et documents autour d'un dossier client, d'une affaire, d'une thématique.

    - + {list.length === 0 ? ( @@ -76,18 +93,35 @@ export default async function ProjectsPage() { {p.description}

    )} + + + + {p.convCount} + conversations + + + + {docCounts.get(p.id) ?? 0} + documents + +
    - + {p.convCount} + conversations - - {p.docCount} + + {docCounts.get(p.id) ?? 0} + documents {new Date(p.updatedAt).toLocaleDateString("fr-FR")} - + diff --git a/src/app/(app)/settings/connectors/actions.ts b/src/app/(app)/settings/connectors/actions.ts index ab5b48c..f33ffd2 100644 --- a/src/app/(app)/settings/connectors/actions.ts +++ b/src/app/(app)/settings/connectors/actions.ts @@ -9,6 +9,8 @@ import { connectorKeys } from "@/db/schema"; import { encrypt } from "@/lib/crypto"; import { CONNECTOR_CATALOG, CONNECTOR_TYPES } from "@/lib/connectors/catalog"; import { recordAudit } from "@/lib/audit"; +import { testPisteConnection } from "@/lib/connectors/piste"; +import { testPappersConnection } from "@/lib/connectors/pappers"; const baseSchema = z.object({ type: z.enum(CONNECTOR_TYPES as [string, ...string[]]), @@ -76,6 +78,33 @@ export async function createConnectorKey( return { ok: true }; } +/** + * R5 : teste les identifiants d'un connecteur (OAuth PISTE / token Pappers) et + * persiste le résultat (lastTestStatus/lastTestedAt). Consomme un appel API + * réel — déclenché explicitement par l'utilisateur. + */ +export async function testConnectorKey(id: string): Promise { + const userId = await requireUserId(); + const [key] = await db + .select({ type: connectorKeys.type }) + .from(connectorKeys) + .where(and(eq(connectorKeys.id, id), eq(connectorKeys.userId, userId))) + .limit(1); + if (!key) return null; + + const status = + key.type === "piste" + ? await testPisteConnection(userId) + : await testPappersConnection(userId); + + await db + .update(connectorKeys) + .set({ lastTestedAt: new Date(), lastTestStatus: status }) + .where(and(eq(connectorKeys.id, id), eq(connectorKeys.userId, userId))); + revalidatePath("/settings/connectors"); + return status; +} + export async function deleteConnectorKey(id: string): Promise { const userId = await requireUserId(); const [target] = await db @@ -176,17 +205,24 @@ export async function updateConnectorKey( return { ok: true }; } -export async function toggleConnectorKeyActive(id: string): Promise { +export async function toggleConnectorKeyActive( + id: string +): Promise { const userId = await requireUserId(); const [current] = await db .select({ isActive: connectorKeys.isActive }) .from(connectorKeys) .where(and(eq(connectorKeys.id, id), eq(connectorKeys.userId, userId))) .limit(1); - if (!current) return; - await db - .update(connectorKeys) - .set({ isActive: !current.isActive }) - .where(and(eq(connectorKeys.id, id), eq(connectorKeys.userId, userId))); + if (!current) return { ok: false, error: "Connecteur introuvable." }; + try { + await db + .update(connectorKeys) + .set({ isActive: !current.isActive }) + .where(and(eq(connectorKeys.id, id), eq(connectorKeys.userId, userId))); + } catch { + return { ok: false, error: "Impossible de modifier l'état du connecteur." }; + } revalidatePath("/settings/connectors"); + return { ok: true }; } diff --git a/src/app/(app)/settings/connectors/connector-card.tsx b/src/app/(app)/settings/connectors/connector-card.tsx index 0e9ee2e..66bce82 100644 --- a/src/app/(app)/settings/connectors/connector-card.tsx +++ b/src/app/(app)/settings/connectors/connector-card.tsx @@ -7,8 +7,10 @@ import { IconDots, IconExternalLink, IconKey, + IconPlayerPlay, IconTrash, } from "@tabler/icons-react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -51,6 +53,7 @@ import { cn } from "@/lib/utils"; import { createConnectorKey, deleteConnectorKey, + testConnectorKey, toggleConnectorKeyActive, updateConnectorKey, } from "./actions"; @@ -137,7 +140,10 @@ export function ConnectorCard({ type, keys }: Props) { checked={primary.isActive} disabled={pending} onCheckedChange={() => { - startTransition(() => toggleConnectorKeyActive(primary.id)); + startTransition(async () => { + const result = await toggleConnectorKeyActive(primary.id); + if (!result.ok) toast.error(result.error); + }); }} aria-label="Activer ce connecteur" /> @@ -198,6 +204,24 @@ export function ConnectorCard({ type, keys }: Props) { + + startTransition(async () => { + const status = await testConnectorKey(primary.id); + if (status === "ok") toast.success("Connexion réussie"); + else if (status === "auth_error") + toast.error("Identifiants refusés (401/403)"); + else if (status === "config_error") + toast.error("Connecteur non configuré ou désactivé"); + else if (status) + toast.error("Connexion impossible (réseau/serveur)"); + }) + } + > + + Tester la connexion + + {isConfigured && primary.lastTestStatus && ( + + )} +
    Débloque : @@ -224,6 +252,22 @@ export function ConnectorCard({ type, keys }: Props) { {u} ))} + {meta.comingSoon && meta.comingSoon.length > 0 && ( + <> + + à venir : + + {meta.comingSoon.map((u) => ( + + {u} + + ))} + + )}
    {isConfigured && ( @@ -351,6 +395,20 @@ export function ConnectorCard({ type, keys }: Props) { {u} ))} + {meta.comingSoon && meta.comingSoon.length > 0 && ( + <> + à venir : + {meta.comingSoon.map((u) => ( + + {u} + + ))} + + )}
    ); } + +/** Dernier résultat du test de connexion d'un connecteur (R5). */ +function ConnectorTestBadge({ status }: { status: string }) { + const map: Record = { + ok: { label: "Connecté", cls: "text-success border-success/40" }, + auth_error: { + label: "Auth refusée", + cls: "text-destructive border-destructive/40", + }, + config_error: { + label: "Non configuré", + cls: "text-warning border-warning/40", + }, + network_error: { + label: "Injoignable", + cls: "text-destructive border-destructive/40", + }, + }; + const m = map[status] ?? { label: status, cls: "text-muted-foreground" }; + return ( + + Dernier test : {m.label} + + ); +} diff --git a/src/app/(app)/settings/mcp/actions.ts b/src/app/(app)/settings/mcp/actions.ts index 1878906..39f418e 100644 --- a/src/app/(app)/settings/mcp/actions.ts +++ b/src/app/(app)/settings/mcp/actions.ts @@ -78,16 +78,20 @@ export async function createMcpServer( headersTag = blob.tag; } + let inserted: typeof mcpServers.$inferSelect; try { - await db.insert(mcpServers).values({ - userId, - label: parsed.data.label, - transport: parsed.data.transport, - url: parsed.data.url, - headersCiphertext, - headersIv, - headersTag, - }); + [inserted] = await db + .insert(mcpServers) + .values({ + userId, + label: parsed.data.label, + transport: parsed.data.transport, + url: parsed.data.url, + headersCiphertext, + headersIv, + headersTag, + }) + .returning(); } catch (err) { const msg = err instanceof Error ? err.message : "Erreur"; if (msg.includes("mcp_servers_user_label_idx")) { @@ -96,7 +100,25 @@ export async function createMcpServer( return { ok: false, error: "Impossible de créer le serveur MCP." }; } + // H24 : sync best-effort à la création — l'utilisateur voit immédiatement les + // outils découverts (ou l'erreur), sans devoir cliquer « Synchroniser ». Un + // échec de sync ne fait PAS échouer la création (il est persisté). + try { + const tools: CachedMcpTool[] = await mcpListTools(inserted); + await db + .update(mcpServers) + .set({ toolsJson: tools, lastSyncedAt: new Date(), lastSyncError: null }) + .where(eq(mcpServers.id, inserted.id)); + } catch (err) { + const msg = err instanceof Error ? err.message : "Erreur inconnue"; + await db + .update(mcpServers) + .set({ lastSyncedAt: new Date(), lastSyncError: msg.slice(0, 500) }) + .where(eq(mcpServers.id, inserted.id)); + } + revalidatePath("/settings/mcp"); + revalidatePath("/chat"); return { ok: true }; } @@ -108,19 +130,26 @@ export async function deleteMcpServer(id: string): Promise { revalidatePath("/settings/mcp"); } -export async function toggleMcpServerActive(id: string): Promise { +export async function toggleMcpServerActive( + id: string +): Promise { const userId = await requireUserId(); const [current] = await db .select({ isActive: mcpServers.isActive }) .from(mcpServers) .where(and(eq(mcpServers.id, id), eq(mcpServers.userId, userId))) .limit(1); - if (!current) return; - await db - .update(mcpServers) - .set({ isActive: !current.isActive }) - .where(and(eq(mcpServers.id, id), eq(mcpServers.userId, userId))); + if (!current) return { ok: false, error: "Serveur MCP introuvable." }; + try { + await db + .update(mcpServers) + .set({ isActive: !current.isActive }) + .where(and(eq(mcpServers.id, id), eq(mcpServers.userId, userId))); + } catch { + return { ok: false, error: "Impossible de modifier l'état du serveur." }; + } revalidatePath("/settings/mcp"); + return { ok: true }; } export async function syncMcpServer(id: string): Promise { diff --git a/src/app/(app)/settings/mcp/mcp-row.tsx b/src/app/(app)/settings/mcp/mcp-row.tsx index 1a0bedb..562e5bc 100644 --- a/src/app/(app)/settings/mcp/mcp-row.tsx +++ b/src/app/(app)/settings/mcp/mcp-row.tsx @@ -10,6 +10,7 @@ import { IconRefresh, IconTrash, } from "@tabler/icons-react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; import { @@ -70,6 +71,19 @@ export function McpRow({ entry }: { entry: McpServer }) {
    {entry.url}
    + {toolCount > 0 && entry.toolsJson && ( +
    + {entry.toolsJson.map((t) => ( + + {t.name} + + ))} +
    + )} {entry.lastSyncError && (
    {entry.lastSyncError} @@ -81,7 +95,10 @@ export function McpRow({ entry }: { entry: McpServer }) { checked={entry.isActive} disabled={pending} onCheckedChange={() => { - startTransition(() => toggleMcpServerActive(entry.id)); + startTransition(async () => { + const result = await toggleMcpServerActive(entry.id); + if (!result.ok) toast.error(result.error); + }); }} aria-label="Activer ce serveur" /> diff --git a/src/app/(app)/settings/models/library/library-browser.tsx b/src/app/(app)/settings/models/library/library-browser.tsx index fffdf93..b22f73a 100644 --- a/src/app/(app)/settings/models/library/library-browser.tsx +++ b/src/app/(app)/settings/models/library/library-browser.tsx @@ -23,6 +23,16 @@ import { } from "@/components/ui/select"; import { cn } from "@/lib/utils"; import { PROVIDER_CATALOG, type ProviderType } from "@/lib/providers/catalog"; +import { MODEL_PRICING } from "@/lib/providers/pricing"; + +/** H23 : prix par M de tokens (entrée/sortie) pour signaler le coût AVANT + * d'activer un modèle. « prix inconnu » plutôt qu'un faux « gratuit ». */ +function formatModelPrice(modelId: string): string { + const p = MODEL_PRICING[modelId]; + if (!p) return "prix inconnu"; + const sym = p.currency === "EUR" ? "€" : "$"; + return `${p.inputPerMillion} / ${p.outputPerMillion} ${sym}/M`; +} import type { LiveModel } from "@/lib/providers/live-catalog"; import { addModelsBulk } from "../actions"; @@ -380,6 +390,12 @@ export function LibraryBrowser({ {m.id} + + {formatModelPrice(m.id)} + {added && ( · déjà ajouté diff --git a/src/app/(app)/settings/models/library/page.tsx b/src/app/(app)/settings/models/library/page.tsx index 1563160..74445a9 100644 --- a/src/app/(app)/settings/models/library/page.tsx +++ b/src/app/(app)/settings/models/library/page.tsx @@ -49,7 +49,7 @@ export default async function ModelLibraryPage() { Choisissez un provider, parcourez son catalogue live, cochez les modèles que vous voulez rendre disponibles dans Louis. Seuls les modèles ajoutés ici apparaîtront dans les pickers du Chat et du - Bureau. + Board.

    diff --git a/src/app/(app)/settings/models/page.tsx b/src/app/(app)/settings/models/page.tsx index 81322ff..53815a5 100644 --- a/src/app/(app)/settings/models/page.tsx +++ b/src/app/(app)/settings/models/page.tsx @@ -61,7 +61,7 @@ export default async function MyModelsPage() {

    Les modèles que vous avez ajoutés à votre plateforme. Seuls ceux - listés ici apparaissent dans les pickers du Chat et du Bureau. + listés ici apparaissent dans les pickers du Chat et du Board. Parcourez la bibliothèque pour découvrir et ajouter de nouveaux modèles.

    @@ -79,7 +79,7 @@ export default async function MyModelsPage() { icon={IconCircleCheck} label="Modèles ajoutés" value={liveEnabled.length} - hint="disponibles dans Chat & Bureau" + hint="disponibles dans Chat & Board" />

    Allez explorer la bibliothèque pour choisir les modèles que vous - voulez rendre disponibles dans le Chat et le Bureau. + voulez rendre disponibles dans le Chat et le Board.

    + + + + Ajouter des documents + + Les documents ajoutés apparaîtront « en attente » ; lancez ensuite + l'extraction. + + +
    + {availableDocuments.map((d) => ( + + ))} +
    + + + + +
    + + ); +} diff --git a/src/app/(app)/tabular-reviews/[id]/auto-refresh.tsx b/src/app/(app)/tabular-reviews/[id]/auto-refresh.tsx index babf9b5..4035e2d 100644 --- a/src/app/(app)/tabular-reviews/[id]/auto-refresh.tsx +++ b/src/app/(app)/tabular-reviews/[id]/auto-refresh.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { useRouter } from "next/navigation"; +import { toast } from "sonner"; import { Button } from "@/components/ui/button"; /** @@ -23,6 +24,18 @@ export function AutoRefresh({ const [paused, setPaused] = useState(false); const [announce, setAnnounce] = useState(""); + // H15-d : notifie la fin de traitement exactement une fois, à la transition + // running → terminé. wasRunning démarre à false → pas de faux positif au + // chargement d'une page déjà 100 % traitée. + const wasRunning = useRef(false); + useEffect(() => { + if (wasRunning.current && !hasRunning) { + toast.success("Extraction terminée."); + setAnnounce("Extraction terminée."); + } + wasRunning.current = hasRunning; + }, [hasRunning]); + useEffect(() => { if (!hasRunning || paused) return; @@ -56,7 +69,15 @@ export function AutoRefresh({ }; }, [hasRunning, intervalMs, router, paused]); - if (!hasRunning) return null; + // Quand plus rien ne tourne, on garde la région live montée pour annoncer + // la fin (le toast est déjà déclenché par l'effet ci-dessus). + if (!hasRunning) { + return ( + + {announce} + + ); + } return ( <> diff --git a/src/app/(app)/tabular-reviews/[id]/column-edit-popover.tsx b/src/app/(app)/tabular-reviews/[id]/column-edit-popover.tsx index fe94809..f10727c 100644 --- a/src/app/(app)/tabular-reviews/[id]/column-edit-popover.tsx +++ b/src/app/(app)/tabular-reviews/[id]/column-edit-popover.tsx @@ -24,7 +24,11 @@ import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import type { ReviewColumn, ReviewColumnFormat } from "@/db/schema"; -import { updateReviewColumn, deleteReviewColumn } from "../actions"; +import { + updateReviewColumn, + deleteReviewColumn, + rerunReviewColumn, +} from "../actions"; const FORMAT_LABELS: Record = { text: "Texte", @@ -79,6 +83,31 @@ export function ColumnEditPopover({ reviewId, column }: Props) { }); } + function handleSaveAndRerun() { + setError(null); + startTransition(async () => { + const saved = await updateReviewColumn(reviewId, column.id, { + label: label.trim(), + prompt: prompt.trim(), + format, + }); + if (!saved.ok) { + setError(saved.error); + return; + } + const rerun = await rerunReviewColumn(reviewId, column.id); + if (!rerun.ok) { + setError(rerun.error); + return; + } + setOpen(false); + router.refresh(); + toast.success("Colonne enregistrée — ré-extraction lancée", { + description: label.trim(), + }); + }); + } + function handleDelete() { startTransition(async () => { const result = await deleteReviewColumn(reviewId, column.id); @@ -167,11 +196,12 @@ export function ColumnEditPopover({ reviewId, column }: Props) { onChange={(e) => setPrompt(e.target.value)} maxLength={500} rows={4} - className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm leading-snug focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40" + className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm leading-snug focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/40" />

    - Décrivez ce que Louis doit extraire — l'instruction est - envoyée au modèle pour chaque document de l'analyse. + Décrivez ce que Louis doit extraire. Modifier le prompt ne + recalcule pas les valeurs déjà extraites — utilisez « Ré-extraire » + pour relancer cette colonne sur tous les documents.

    {error && ( @@ -193,18 +223,19 @@ export function ColumnEditPopover({ reviewId, column }: Props) { type="button" variant="ghost" size="sm" - onClick={() => setOpen(false)} - disabled={pending} + onClick={handleSave} + disabled={pending || !label.trim() || !prompt.trim()} > - Annuler + Enregistrer
    diff --git a/src/app/(app)/tabular-reviews/[id]/page.tsx b/src/app/(app)/tabular-reviews/[id]/page.tsx index bab5cd1..b7f1aac 100644 --- a/src/app/(app)/tabular-reviews/[id]/page.tsx +++ b/src/app/(app)/tabular-reviews/[id]/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { notFound, redirect } from "next/navigation"; -import { and, eq } from "drizzle-orm"; +import { and, eq, isNotNull } from "drizzle-orm"; import { IconArrowLeft, IconTable, @@ -17,6 +17,7 @@ import { import { ReviewGrid } from "./review-grid"; import { ReviewActions } from "./review-actions"; import { AutoRefresh } from "./auto-refresh"; +import { AddDocumentsDialog } from "./add-documents-dialog"; type Params = { id: string }; @@ -68,6 +69,18 @@ export default async function TabularReviewDetailPage({ ).length; const runningCount = rows.filter((r) => r.status === "running").length; + // H15-c : documents indexables pas encore dans l'analyse, pour l'ajout. + const existingDocIds = new Set(rows.map((r) => r.documentId)); + const allUserDocs = await db + .select({ id: documents.id, filename: documents.filename }) + .from(documents) + .where( + and(eq(documents.userId, userId), isNotNull(documents.extractedText)) + ); + const availableDocuments = allUserDocs.filter( + (d) => !existingDocIds.has(d.id) + ); + return (
    - +
    + + +
    diff --git a/src/app/(app)/tabular-reviews/[id]/review-actions.tsx b/src/app/(app)/tabular-reviews/[id]/review-actions.tsx index aaf9b04..fc5fd3e 100644 --- a/src/app/(app)/tabular-reviews/[id]/review-actions.tsx +++ b/src/app/(app)/tabular-reviews/[id]/review-actions.tsx @@ -7,6 +7,7 @@ import { IconDots, IconTrash, IconRefresh, + IconFileSpreadsheet, } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; @@ -72,6 +73,18 @@ export function ReviewActions({ reviewId, pendingCount, totalRows }: Props) { + { + // Route GET attachment → le navigateur télécharge et reste sur + // la page. + window.location.href = `/api/tabular-reviews/${reviewId}/export`; + }} + > + + Exporter en CSV + + setRerunOpen(true)} diff --git a/src/app/(app)/tabular-reviews/[id]/review-grid.tsx b/src/app/(app)/tabular-reviews/[id]/review-grid.tsx index 91c0167..12e2410 100644 --- a/src/app/(app)/tabular-reviews/[id]/review-grid.tsx +++ b/src/app/(app)/tabular-reviews/[id]/review-grid.tsx @@ -1,17 +1,19 @@ "use client"; import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; import { IconCheck, IconAlertTriangle, IconClock, IconTrash, + IconRefresh, } from "@tabler/icons-react"; import { Spinner } from "@/components/ui/spinner"; import { ConfirmDeleteDialog } from "@/components/confirm-delete-dialog"; import { ColumnEditPopover } from "./column-edit-popover"; import type { ReviewColumn, TabularReviewRow } from "@/db/schema"; -import { deleteReviewRow } from "../actions"; +import { deleteReviewRow, rerunReviewRow } from "../actions"; type Row = TabularReviewRow & { filename: string }; @@ -98,8 +100,17 @@ export function ReviewGrid({ columns, rows, reviewId }: Props) { } function RowCard({ row, columns }: { row: Row; columns: ReviewColumn[] }) { + const router = useRouter(); const [pending, startTransition] = useTransition(); const [deleteOpen, setDeleteOpen] = useState(false); + + function rerun() { + startTransition(async () => { + await rerunReviewRow(row.id); + router.refresh(); + }); + } + return (
    @@ -109,15 +120,26 @@ function RowCard({ row, columns }: { row: Row; columns: ReviewColumn[] }) {
    - +
    + + +
    { + await rerunReviewRow(row.id); + router.refresh(); + }); + } + return ( - +
    + + +
    [c.id, z.string().describe(describeColumn(c))]) + ) + ); +} + async function requireUserId(): Promise { const session = await auth(); if (!session?.user?.id) throw new Error("Unauthorized"); @@ -121,6 +153,167 @@ export async function deleteReviewRow(rowId: string): Promise { revalidatePath(`/tabular-reviews/${row.reviewId}`); } +/** + * H15-a : ré-extrait UNE ligne (toutes ses colonnes), même si elle était déjà + * « ok ». Utile pour relancer un document dont l'extraction a déçu, sans + * toucher aux autres lignes. + */ +export async function rerunReviewRow(rowId: string): Promise { + const userId = await requireUserId(); + const [row] = await db + .select({ + reviewId: tabularReviewRows.reviewId, + documentId: tabularReviewRows.documentId, + providerKeyId: tabularReviews.providerKeyId, + modelId: tabularReviews.modelId, + columns: tabularReviews.columns, + }) + .from(tabularReviewRows) + .innerJoin( + tabularReviews, + eq(tabularReviews.id, tabularReviewRows.reviewId) + ) + .where( + and( + eq(tabularReviewRows.id, rowId), + eq(tabularReviews.userId, userId) + ) + ) + .limit(1); + if (!row || !row.providerKeyId || !row.modelId || !row.columns?.length) return; + + await db + .update(tabularReviewRows) + .set({ status: "running", error: null, updatedAt: new Date() }) + .where(eq(tabularReviewRows.id, rowId)); + revalidatePath(`/tabular-reviews/${row.reviewId}`); + + const { reviewId, documentId, providerKeyId, modelId, columns } = row; + after(async () => { + try { + await processReviewRows({ + userId, + reviewId, + providerKeyId, + modelId, + columns, + rows: [{ id: rowId, documentId }], + }); + } catch (err) { + log.error("tabular-reviews", "rerun row failed", { + error: err instanceof Error ? err.message : err, + }); + } + }); +} + +/** + * H15-b : ré-extrait UNE colonne sur toutes les lignes — à déclencher après + * avoir modifié son prompt. Le merge dans extractRow préserve les valeurs des + * autres colonnes. + */ +export async function rerunReviewColumn( + reviewId: string, + columnId: string +): Promise { + const userId = await requireUserId(); + const [review] = await db + .select() + .from(tabularReviews) + .where( + and(eq(tabularReviews.id, reviewId), eq(tabularReviews.userId, userId)) + ) + .limit(1); + if (!review) return { ok: false, error: "Analyse introuvable." }; + if (!review.providerKeyId || !review.modelId) { + return { ok: false, error: "Configuration du modèle incomplète." }; + } + const col = review.columns.find((c) => c.id === columnId); + if (!col) return { ok: false, error: "Colonne introuvable." }; + + const rows = await db + .update(tabularReviewRows) + .set({ status: "running", error: null, updatedAt: new Date() }) + .where(eq(tabularReviewRows.reviewId, reviewId)) + .returning({ + id: tabularReviewRows.id, + documentId: tabularReviewRows.documentId, + }); + revalidatePath(`/tabular-reviews/${reviewId}`); + if (rows.length === 0) return { ok: true }; + + const { providerKeyId, modelId } = review; + after(async () => { + try { + await processReviewRows({ + userId, + reviewId, + providerKeyId, + modelId, + columns: [col], + rows, + }); + } catch (err) { + log.error("tabular-reviews", "rerun column failed", { + error: err instanceof Error ? err.message : err, + }); + } + }); + return { ok: true }; +} + +const addDocsSchema = z.object({ + documentIds: z.array(z.uuid()).min(1).max(200), +}); + +/** + * H15-c : ajoute des documents à une analyse existante (la promesse « vous + * pourrez en ajouter plus tard »). N'insère que les documents de + * l'utilisateur avec du texte extrait ; pas de doublon (index unique). + */ +export async function addReviewDocuments( + reviewId: string, + documentIds: string[] +): Promise { + const userId = await requireUserId(); + const parsed = addDocsSchema.safeParse({ documentIds }); + if (!parsed.success) return { ok: false, error: "Sélection invalide." }; + + const [review] = await db + .select({ id: tabularReviews.id }) + .from(tabularReviews) + .where( + and(eq(tabularReviews.id, reviewId), eq(tabularReviews.userId, userId)) + ) + .limit(1); + if (!review) return { ok: false, error: "Analyse introuvable." }; + + const validDocs = await db + .select({ id: documents.id }) + .from(documents) + .where( + and( + eq(documents.userId, userId), + inArray(documents.id, parsed.data.documentIds), + isNotNull(documents.extractedText) + ) + ); + if (validDocs.length === 0) { + return { + ok: false, + error: "Aucun document éligible (texte non extrait ?).", + }; + } + + await db + .insert(tabularReviewRows) + .values(validDocs.map((d) => ({ reviewId, documentId: d.id }))) + .onConflictDoNothing(); + + revalidatePath(`/tabular-reviews/${reviewId}`); + return { ok: true }; +} + /** * Lance l'extraction pour toutes les lignes pending/error d'un review. * @@ -148,6 +341,24 @@ export async function runTabularReview(reviewId: string): Promise { if (!review.providerKeyId || !review.modelId) return; if (!review.columns || review.columns.length === 0) return; + // H15-f : requalifie d'abord les lignes « running » abandonnées (au-delà du + // seuil) en « error », pour qu'elles soient reprises par l'update suivant. + // Les « running » récentes (run légitime en cours) ne sont pas touchées. + await db + .update(tabularReviewRows) + .set({ + status: "error", + error: "Traitement interrompu — relancé.", + updatedAt: new Date(), + }) + .where( + and( + eq(tabularReviewRows.reviewId, reviewId), + eq(tabularReviewRows.status, "running"), + lt(tabularReviewRows.updatedAt, new Date(Date.now() - STALE_RUNNING_MS)) + ) + ); + // Snapshot des lignes à traiter, en une seule update pour libérer le // request handler immédiatement. const rowsToProcess = await db @@ -210,11 +421,7 @@ async function processReviewRows({ const key = await loadProviderKey(userId, providerKeyId); const model = modelFromKey(key, modelId); - const valuesSchema = z.object( - Object.fromEntries( - columns.map((c) => [c.id, z.string().describe(c.prompt)]) - ) - ); + const valuesSchema = buildValuesSchema(columns); // Concurrency limiter — une "fenêtre coulissante" de N promesses en vol. let cursor = 0; @@ -281,10 +488,21 @@ async function extractRow({ prompt: `Document : "${doc.filename}"\n\n${promptDoc}\n\nExtrais les valeurs demandées par les descriptions des champs.`, }); + // Merge (pas overwrite) : préserve les valeurs des AUTRES colonnes — clé + // pour la ré-extraction d'une seule colonne (H15-b). Pour un run complet, + // les valeurs de départ sont vides, donc merge = set. + const [existing] = await db + .select({ values: tabularReviewRows.values }) + .from(tabularReviewRows) + .where(eq(tabularReviewRows.id, row.id)) + .limit(1); await db .update(tabularReviewRows) .set({ - values: result.output as Record, + values: { + ...(existing?.values ?? {}), + ...(result.output as Record), + }, status: "ok", error: null, updatedAt: new Date(), diff --git a/src/app/(app)/workflows/add-workflow-dialog.tsx b/src/app/(app)/workflows/add-workflow-dialog.tsx index 7b030b7..39e73cb 100644 --- a/src/app/(app)/workflows/add-workflow-dialog.tsx +++ b/src/app/(app)/workflows/add-workflow-dialog.tsx @@ -89,7 +89,7 @@ export function AddWorkflowDialog() { maxLength={4000} rows={6} placeholder="Le texte qui sera inséré dans le composer du chat…" - className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
    diff --git a/src/app/(app)/workflows/page.tsx b/src/app/(app)/workflows/page.tsx index 79d5c35..f8380b4 100644 --- a/src/app/(app)/workflows/page.tsx +++ b/src/app/(app)/workflows/page.tsx @@ -5,6 +5,7 @@ import { IconSparkles } from "@tabler/icons-react"; import { auth } from "@/auth"; import { db } from "@/db"; import { workflows } from "@/db/schema"; +import { EmptyState } from "@/components/empty-state"; import { WorkflowCard } from "./workflow-card"; import { AddWorkflowDialog } from "./add-workflow-dialog"; @@ -27,12 +28,12 @@ export default async function WorkflowsPage() { Bibliothèque cabinet

    - Workflows. + Trames.

    - Prompts réutilisables — résumé d'arrêt, analyse de clause, - due diligence. Insérez-les d'un clic dans une conversation - via l'icône{" "} + Prompts réutilisables du cabinet — résumé d'arrêt, analyse de + clause, due diligence. Insérez-les d'un clic dans une + conversation via l'icône{" "} .

    @@ -40,7 +41,25 @@ export default async function WorkflowsPage() { {list.length === 0 ? ( - + +

    + Une trame est un prompt réutilisable que vous insérez d'un clic + dans une conversation. Créez-en un depuis votre pratique — Louis ne + livre pas de templates par défaut, c'est votre cabinet qui + définit sa bibliothèque. +

    +

    + Besoin d'inspiration ?{" "} + + Importez des modèles de skills juridiques + {" "} + comme point de départ — relisez-les et adaptez-les avant de les + utiliser. +

    +
    ) : (
      {list.map((w) => ( @@ -51,30 +70,3 @@ export default async function WorkflowsPage() { ); } - -function EmptyState() { - return ( -
      -

      - Pas encore de workflow. -

      -

      - Un workflow est un prompt réutilisable que vous insérez d'un clic - dans une conversation. Créez-en un depuis votre pratique — Louis ne - livre pas de templates par défaut, c'est votre cabinet qui définit - sa bibliothèque. -

      -

      - Besoin d'inspiration ?{" "} - - Importez des modèles de skills juridiques - {" "} - comme point de départ — relisez-les et adaptez-les avant de les - utiliser. -

      -
      - ); -} diff --git a/src/app/(app)/workflows/workflow-card.tsx b/src/app/(app)/workflows/workflow-card.tsx index 03a5a3c..553b19d 100644 --- a/src/app/(app)/workflows/workflow-card.tsx +++ b/src/app/(app)/workflows/workflow-card.tsx @@ -139,7 +139,7 @@ export function WorkflowCard({ workflow }: { workflow: Workflow }) { required maxLength={4000} rows={6} - className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50" + className="w-full resize-y rounded-md border border-input bg-card px-3 py-2 text-sm shadow-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/50" />
    {error && ( diff --git a/src/app/api/admin/audit/export/route.ts b/src/app/api/admin/audit/export/route.ts new file mode 100644 index 0000000..4c83866 --- /dev/null +++ b/src/app/api/admin/audit/export/route.ts @@ -0,0 +1,101 @@ +import { and, desc, eq, gte, lte } from "drizzle-orm"; +import { db } from "@/db"; +import { auditLog, users } from "@/db/schema"; +import { requireAdmin } from "@/lib/auth/permissions"; +import { labelForAction } from "@/lib/audit/labels"; + +/** + * H21 : export du journal d'audit (CSV/JSON), filtres identiques à la page. + * requireAdmin LÈVE une erreur → on la catch pour renvoyer un 403 propre + * (sans catch, un throw dans un route handler produit un 500). Le `meta` + * (IP, user-agent…) est de la donnée perso RGPD → réservé aux admins. + */ +export async function GET(req: Request): Promise { + try { + await requireAdmin(); + } catch { + return new Response("Forbidden", { status: 403 }); + } + + const url = new URL(req.url); + const format = url.searchParams.get("format") === "json" ? "json" : "csv"; + const action = url.searchParams.get("action"); + const fromRaw = url.searchParams.get("from"); + const toRaw = url.searchParams.get("to"); + const from = fromRaw ? new Date(fromRaw) : null; + const to = toRaw ? new Date(`${toRaw}T23:59:59`) : null; + + const conds = []; + if (action && action !== "all") conds.push(eq(auditLog.action, action)); + if (from && !Number.isNaN(from.getTime())) + conds.push(gte(auditLog.createdAt, from)); + if (to && !Number.isNaN(to.getTime())) conds.push(lte(auditLog.createdAt, to)); + const where = conds.length > 0 ? and(...conds) : undefined; + + const rows = await db + .select({ + action: auditLog.action, + target: auditLog.target, + meta: auditLog.meta, + createdAt: auditLog.createdAt, + actorEmail: users.email, + actorName: users.name, + }) + .from(auditLog) + .leftJoin(users, eq(users.id, auditLog.userId)) + .where(where) + .orderBy(desc(auditLog.createdAt)) + .limit(10_000); + + if (format === "json") { + const payload = rows.map((r) => ({ + date: new Date(r.createdAt).toISOString(), + action: r.action, + label: labelForAction(r.action), + actor: r.actorName ?? r.actorEmail ?? null, + target: r.target, + meta: r.meta ?? null, + })); + return new Response(JSON.stringify(payload, null, 2), { + status: 200, + headers: { + "Content-Type": "application/json; charset=utf-8", + "Content-Disposition": 'attachment; filename="audit.json"', + "Cache-Control": "no-store", + }, + }); + } + + const header = ["Date", "Action", "Acteur", "Cible", "Détails"]; + const lines = [header.map(csvCell).join(";")]; + for (const r of rows) { + lines.push( + [ + new Date(r.createdAt).toISOString(), + labelForAction(r.action), + r.actorName ?? r.actorEmail ?? "", + r.target ?? "", + r.meta ? JSON.stringify(r.meta) : "", + ] + .map(csvCell) + .join(";") + ); + } + const csv = "" + lines.join("\r\n") + "\r\n"; + return new Response(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": 'attachment; filename="audit.csv"', + "Cache-Control": "no-store", + }, + }); +} + +/** Échappement CSV + neutralisation de l'injection de formule. */ +function csvCell(v: string): string { + let s = v ?? ""; + if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; + if (/[";\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 809861d..f6e8daf 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -3,7 +3,7 @@ import { createUIMessageStreamResponse, type UIMessage, } from "ai"; -import { and, eq, gte, inArray } from "drizzle-orm"; +import { and, eq, inArray } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; import { @@ -11,11 +11,15 @@ import { conversations, documents, messages, - users, type SavedPart, } from "@/db/schema"; import { loadProviderKey, modelFromKey } from "@/lib/providers/factory"; -import { aggregateCosts } from "@/lib/providers/pricing"; +import { getProjectScope } from "@/lib/projects/scope"; +import { indexMessageForProject } from "@/lib/rag/message-search"; +import { + getMonthlySpendCents, + getUserMonthlyQuotaCents, +} from "@/lib/usage/quota"; import { rateLimit, tooManyRequests } from "@/lib/rate-limit"; import { getEnabledSkills } from "@/app/(app)/settings/skills/actions"; import { @@ -50,42 +54,19 @@ export async function POST(req: Request) { const rl = await rateLimit("chat", userId); if (!rl.allowed) return tooManyRequests(rl); - // Enforcement du quota mensuel admin. Si le user a un plafond défini, - // on calcule sa dépense IA depuis le 1er du mois et on refuse toute - // nouvelle requête si le seuil est atteint. Audit/usage continue d'être - // tracé via les colonnes messages.inputTokens/outputTokens habituelles. - const [userRow] = await db - .select({ monthlyQuotaCents: users.monthlyQuotaCents }) - .from(users) - .where(eq(users.id, userId)) - .limit(1); - if (userRow?.monthlyQuotaCents != null) { - const monthStart = new Date(); - monthStart.setDate(1); - monthStart.setHours(0, 0, 0, 0); - const usageRows = await db - .select({ - modelId: messages.modelId, - inputTokens: messages.inputTokens, - outputTokens: messages.outputTokens, - }) - .from(messages) - .innerJoin(conversations, eq(conversations.id, messages.conversationId)) - .where( - and( - eq(conversations.userId, userId), - eq(messages.role, "assistant"), - gte(messages.createdAt, monthStart) - ) - ); - const totals = aggregateCosts(usageRows); - const spentCents = Math.round((totals.EUR + totals.USD) * 100); - if (spentCents >= userRow.monthlyQuotaCents) { + // Enforcement du quota mensuel admin. Si le user a un plafond défini, on + // calcule sa dépense IA du mois via le helper PARTAGÉ avec l'affichage + // (page usage, dashboard) — même formule, donc le montant montré au membre + // == celui qui déclenche ce blocage 402. + const quotaCents = await getUserMonthlyQuotaCents(userId); + if (quotaCents != null) { + const spentCents = await getMonthlySpendCents(userId); + if (spentCents >= quotaCents) { return new Response( JSON.stringify({ error: "quota_exceeded", spentCents, - quotaCents: userRow.monthlyQuotaCents, + quotaCents, message: "Quota mensuel atteint. Contactez l'administrateur de votre cabinet pour le relever ou attendez le mois suivant.", }), @@ -112,17 +93,25 @@ export async function POST(req: Request) { return new Response("providerKeyId is required", { status: 400 }); } + // Type du provider de la conversation — stampé sur chaque agent_run pour + // que l'audit trail soit lisible (et exportable) sans re-résoudre la clé. + let providerType: string | null = null; try { - await loadProviderKey(userId, providerKeyId); + const pk = await loadProviderKey(userId, providerKeyId); + providerType = pk.type; } catch (err) { const msg = err instanceof Error ? err.message : "Provider error"; return new Response(msg, { status: 400 }); } - // Verify ownership of existing conversation to prevent cross-user injection + // Verify ownership of existing conversation to prevent cross-user injection. + // On récupère aussi son projectId : pour une conversation existante le body + // ne porte pas forcément le projet (chat-shell ne le transmet que pour les + // nouvelles), donc la conversation est la source de vérité du périmètre. + let effectiveProjectId: string | null = null; if (conversationId) { const [conv] = await db - .select({ id: conversations.id }) + .select({ id: conversations.id, projectId: conversations.projectId }) .from(conversations) .where( and(eq(conversations.id, conversationId), eq(conversations.userId, userId)) @@ -131,6 +120,7 @@ export async function POST(req: Request) { if (!conv) { return new Response("Conversation not found", { status: 404 }); } + effectiveProjectId = conv.projectId; } // Résout la pipeline : soit celle pointée par pipelineId (et l'on vérifie @@ -161,26 +151,41 @@ export async function POST(req: Request) { }) .returning({ id: conversations.id }); conversationId = created.id; + effectiveProjectId = projectIdFromBody ?? null; } const finalConversationId = conversationId; + // Périmètre projet (modèle dossier = projet) : documents du sous-arbre du + // dossier-racine + dossier de destination des documents générés. Sert au + // scoping RAG des outils documentaires et à l'historique des conversations. + const projectScope = effectiveProjectId + ? await getProjectScope(userId, effectiveProjectId) + : null; + + let userMessageId: string | null = null; + let userMessageText = ""; const lastUser = uiMessages.at(-1); if (lastUser?.role === "user") { const text = extractTextPreview(lastUser); if (text) { - await db.insert(messages).values({ - conversationId: finalConversationId, - role: "user", - content: text, - // Trace des documents joints à CE tour, pour ré-afficher les pills - // au re-load. Le contenu des docs est injecté dans le system prompt - // plus bas — ici on garde juste la liste d'IDs pour l'UI. - metadata: - documentIds && documentIds.length > 0 - ? { documentIds } - : null, - }); + userMessageText = text; + const [insertedUser] = await db + .insert(messages) + .values({ + conversationId: finalConversationId, + role: "user", + content: text, + // Trace des documents joints à CE tour, pour ré-afficher les pills + // au re-load. Le contenu des docs est injecté dans le system prompt + // plus bas — ici on garde juste la liste d'IDs pour l'UI. + metadata: + documentIds && documentIds.length > 0 + ? { documentIds } + : null, + }) + .returning({ id: messages.id }); + userMessageId = insertedUser.id; } } @@ -257,6 +262,11 @@ export async function POST(req: Request) { let finalText = ""; const finalUsage: { inputTokens?: number; outputTokens?: number } = {}; const agentStarts = new Map(); + // Audit trail multi-agent accumulé pendant le run, inséré en batch dans + // onFinish une fois l'id du message assistant connu (rattachement messageId). + // Accumuler (vs insert immédiat) évite aussi de laisser des runs orphelins + // quand un Stop annule le tour avant l'insertion du message. + const pendingRuns: (typeof agentRuns.$inferInsert)[] = []; const orchestrator = new Orchestrator(pipelineConfig); @@ -281,34 +291,51 @@ export async function POST(req: Request) { messages: uiMessages, documentIds, systemPromptExtras, + projectId: effectiveProjectId, + projectDocumentIds: projectScope?.documentIds, + projectFolderId: projectScope?.folderId ?? null, + // R2 : annulation réelle côté serveur. Quand l'utilisateur clique + // « Stop », DefaultChatTransport abort le fetch → req.signal s'abort + // → propagé jusqu'à streamText, qui coupe l'appel LLM (et la + // facturation), pas seulement le rendu client. + abortSignal: req.signal, }, writer: { write: (part) => writer.write(part as never), merge: (s) => writer.merge(s as never), }, - onEvent: async (event: OrchestratorEvent) => { + onEvent: (event: OrchestratorEvent) => { if (event.type === "agent_start") { agentStarts.set(event.agentId, Date.now()); return; } if (event.type === "agent_finish") { + // R1 : usage agrégé du run = somme de TOUS les agents. Le message + // porte le coût total (pill, page usage, quota) ; le détail par + // agent vit dans agent_runs (audit trail). + finalUsage.inputTokens = + (finalUsage.inputTokens ?? 0) + (event.inputTokens ?? 0); + finalUsage.outputTokens = + (finalUsage.outputTokens ?? 0) + (event.outputTokens ?? 0); + const startedAt = agentStarts.get(event.agentId) ?? Date.now(); - const startedDate = new Date(startedAt); - await db.insert(agentRuns).values({ + pendingRuns.push({ conversationId: finalConversationId, pipelineId: pipelineConfig.id ?? null, pipelineAgentId: isUuid(event.agentId) ? event.agentId : null, role: event.role, label: event.label, - modelId: modelOverride ?? null, - providerType: null, + // H9 : modelId RÉEL de l'agent (def.modelOverride) ; fallback sur + // le modèle global de la conversation quand l'agent hérite. + modelId: event.modelId ?? modelOverride ?? null, + providerType, status: "success", inputTokens: event.inputTokens ?? null, outputTokens: event.outputTokens ?? null, latencyMs: event.latencyMs, output: event.preview ?? null, - startedAt: startedDate, + startedAt: new Date(startedAt), finishedAt: new Date(), }); return; @@ -316,13 +343,14 @@ export async function POST(req: Request) { if (event.type === "agent_error") { const startedAt = agentStarts.get(event.agentId) ?? Date.now(); - await db.insert(agentRuns).values({ + pendingRuns.push({ conversationId: finalConversationId, pipelineId: pipelineConfig.id ?? null, pipelineAgentId: isUuid(event.agentId) ? event.agentId : null, role: event.role, label: event.label, - modelId: modelOverride ?? null, + modelId: event.modelId ?? modelOverride ?? null, + providerType, status: "error", latencyMs: Date.now() - startedAt, error: event.error, @@ -332,8 +360,30 @@ export async function POST(req: Request) { } }, }); + + // R1 : metadata du message. Le client (useChat.onFinish) y lit le + // conversationId (maj URL d'une conversation neuve → /chat?id=…, survit + // au refresh) et l'usage agrégé (pill coût, page usage, quota). Émis + // APRÈS le run pour que finalUsage soit complet. Si l'utilisateur a + // annulé, on n'émet pas (le tour est abandonné, rien à compter). + if (!req.signal.aborted) { + writer.write({ + type: "message-metadata", + messageMetadata: { + conversationId: finalConversationId, + usage: { + inputTokens: finalUsage.inputTokens ?? 0, + outputTokens: finalUsage.outputTokens ?? 0, + }, + }, + }); + } }, onFinish: async ({ messages: streamMessages }) => { + // Décision : un « Stop » n'enregistre PAS de réponse partielle. Le tour + // est annulé proprement (pas de message tronqué, pas d'agent_runs + // orphelins). L'utilisateur relance s'il le souhaite. + if (req.signal.aborted) return; // Reconstitue les parts brutes du dernier message assistant (le // texte final + les tool calls/results) pour les re-render au load. for (const m of streamMessages) { @@ -375,25 +425,63 @@ export async function POST(req: Request) { output: toolPart.output, }); } + } else if (PERSISTED_DATA_PARTS.has(part.type)) { + // H3a : persiste le trail multi-agents (events/outputs/retries) + + // skills détectées pour qu'ils survivent au reload (theatre, + // badges d'étapes, pills « Compétence appliquée »). + const dataPart = part as { type: string; data?: unknown }; + savedParts.push({ + type: "data", + dataType: dataPart.type, + data: capAgentOutput(dataPart.type, dataPart.data), + }); } } } if (!finalText) return; - await db.insert(messages).values({ - conversationId: finalConversationId, - role: "assistant", - content: finalText, - parts: savedParts.length > 0 ? savedParts : null, - inputTokens: finalUsage.inputTokens ?? null, - outputTokens: finalUsage.outputTokens ?? null, - modelId: modelOverride ?? null, - }); + const [insertedAssistant] = await db + .insert(messages) + .values({ + conversationId: finalConversationId, + role: "assistant", + content: finalText, + parts: savedParts.length > 0 ? savedParts : null, + inputTokens: finalUsage.inputTokens ?? null, + outputTokens: finalUsage.outputTokens ?? null, + modelId: modelOverride ?? null, + }) + .returning({ id: messages.id }); + + // H9 : insère l'audit trail multi-agent, rattaché au message assistant + // (messageId), pour qu'il soit relisible/exportable par message. + if (pendingRuns.length > 0) { + await db + .insert(agentRuns) + .values( + pendingRuns.map((r) => ({ ...r, messageId: insertedAssistant.id })) + ); + } + await db .update(conversations) .set({ updatedAt: new Date() }) .where(eq(conversations.id, finalConversationId)); + + // RAG conversations : on indexe les messages de ce tour uniquement + // quand la conversation appartient à un projet (maîtrise du coût + // d'embedding). Best-effort — n'interrompt jamais la réponse. + if (effectiveProjectId) { + if (userMessageId && userMessageText) { + await indexMessageForProject( + userId, + userMessageId, + userMessageText + ); + } + await indexMessageForProject(userId, insertedAssistant.id, finalText); + } }, }); @@ -419,3 +507,25 @@ const UUID_REGEX = function isUuid(value: string): boolean { return UUID_REGEX.test(value); } + +// Data parts du trail multi-agents persistés (H3a). On exclut data-final-text +// (non consommé au rendu) et tout autre data part transitoire. +const PERSISTED_DATA_PARTS = new Set([ + "data-agent-event", + "data-agent-output", + "data-agent-retry", + "data-skills-detected", +]); + +/** Cap la taille du texte intermédiaire persisté (data-agent-output) pour ne + * pas faire exploser la colonne jsonb sur les conversations longues. */ +function capAgentOutput(dataType: string, data: unknown): unknown { + if (dataType !== "data-agent-output") return data; + if (data && typeof data === "object" && "output" in data) { + const d = data as { output?: unknown }; + if (typeof d.output === "string" && d.output.length > 12_000) { + return { ...data, output: `${d.output.slice(0, 12_000)}…` }; + } + } + return data; +} diff --git a/src/app/api/documents/upload/route.ts b/src/app/api/documents/upload/route.ts index ab6765e..eb69041 100644 --- a/src/app/api/documents/upload/route.ts +++ b/src/app/api/documents/upload/route.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from "drizzle-orm"; +import { and, eq, inArray, or, sql } from "drizzle-orm"; import { auth } from "@/auth"; import { db } from "@/db"; import { documents, documentChunks, documentFolders } from "@/db/schema"; @@ -45,8 +45,10 @@ export async function POST(req: Request) { } // When `replaces` is set, this upload is a new version of an existing - // document. We inherit the project assignment from the parent and increment - // the version counter for the whole family. + // document. We inherit the parent's placement (folder = appartenance au + // projet, et le projectId legacy) et increment the version counter for the + // whole family. Sans héritage du folderId, une nouvelle version sortirait + // du périmètre du projet. const replacesRaw = formData.get("replaces"); const replacesId = typeof replacesRaw === "string" && replacesRaw.length > 0 @@ -54,6 +56,7 @@ export async function POST(req: Request) { : null; let parentDocumentId: string | null = null; let projectIdOverride: string | null = null; + let folderIdOverride: string | null = null; let nextVersion = 1; if (replacesId) { const [parent] = await db @@ -61,6 +64,7 @@ export async function POST(req: Request) { id: documents.id, userId: documents.userId, projectId: documents.projectId, + folderId: documents.folderId, parentDocumentId: documents.parentDocumentId, }) .from(documents) @@ -71,6 +75,7 @@ export async function POST(req: Request) { } parentDocumentId = parent.parentDocumentId ?? parent.id; projectIdOverride = parent.projectId; + folderIdOverride = parent.folderId; const [{ max }] = await db .select({ max: sql`COALESCE(MAX(${documents.version}), 0)::int`, @@ -82,8 +87,9 @@ export async function POST(req: Request) { if (parentDocumentId === parent.id && nextVersion < 2) nextVersion = 2; } - // Folder assignment (only on fresh uploads — versions inherit from parent). - let folderIdOverride: string | null = null; + // Folder assignment (only on fresh uploads — versions inherit their + // placement from the parent document). Le dossier détermine l'appartenance + // au projet (modèle dossier = projet). if (!replacesId) { const folderRaw = formData.get("folder"); if (typeof folderRaw === "string" && folderRaw.length > 0) { @@ -183,6 +189,31 @@ export async function POST(req: Request) { } } + // R7 : purge les chunks des versions OBSOLÈTES de la famille. Sans cela, + // ragSearch (qui interroge tous les documents du user) pouvait citer le + // texte d'une v1 remplacée à la place de la v2 courante — un bug de + // correction, pas qu'un détail. On ne supprime QUE les chunks : les rows + // documents (historique des versions) restent intactes. + if (replacesId && parentDocumentId) { + const familyDocs = await db + .select({ id: documents.id }) + .from(documents) + .where( + or( + eq(documents.id, parentDocumentId), + eq(documents.parentDocumentId, parentDocumentId) + ) + ); + const staleIds = familyDocs + .map((d) => d.id) + .filter((id) => id !== docId); + if (staleIds.length > 0) { + await db + .delete(documentChunks) + .where(inArray(documentChunks.documentId, staleIds)); + } + } + return Response.json({ id: docId, extractionStatus, diff --git a/src/app/api/tabular-reviews/[id]/export/route.ts b/src/app/api/tabular-reviews/[id]/export/route.ts new file mode 100644 index 0000000..e76ead6 --- /dev/null +++ b/src/app/api/tabular-reviews/[id]/export/route.ts @@ -0,0 +1,93 @@ +import { and, asc, eq } from "drizzle-orm"; +import { auth } from "@/auth"; +import { db } from "@/db"; +import { documents, tabularReviews, tabularReviewRows } from "@/db/schema"; + +type Params = { id: string }; + +/** + * H14 : export CSV d'une analyse tabulaire — le livrable « comparer un lot de + * contrats dans un tableur ». Séparateur « ; » + BOM UTF-8 pour ouverture + * native dans Excel/LibreOffice (accents préservés). Vérifie la propriété de + * l'analyse (404 sinon, aucun octet de données). + */ +export async function GET( + _req: Request, + { params }: { params: Promise } +) { + const session = await auth(); + if (!session?.user) return new Response("Unauthorized", { status: 401 }); + const userId = session.user.id; + const { id } = await params; + + const [review] = await db + .select({ + id: tabularReviews.id, + name: tabularReviews.name, + columns: tabularReviews.columns, + }) + .from(tabularReviews) + .where(and(eq(tabularReviews.id, id), eq(tabularReviews.userId, userId))) + .limit(1); + if (!review) return new Response("Not found", { status: 404 }); + + const rows = await db + .select({ + filename: documents.filename, + values: tabularReviewRows.values, + status: tabularReviewRows.status, + }) + .from(tabularReviewRows) + .innerJoin(documents, eq(documents.id, tabularReviewRows.documentId)) + .where(eq(tabularReviewRows.reviewId, id)) + .orderBy(asc(tabularReviewRows.createdAt)); + + const header = ["Document", "Statut", ...review.columns.map((c) => c.label)]; + const lines = [header.map(csvCell).join(";")]; + for (const r of rows) { + const cells = [ + r.filename, + STATUS_LABEL[r.status] ?? r.status, + ...review.columns.map((c) => r.values?.[c.id] ?? ""), + ]; + lines.push(cells.map(csvCell).join(";")); + } + + // BOM UTF-8 + CRLF (convention CSV) pour Excel. + const csv = "" + lines.join("\r\n") + "\r\n"; + + const safeName = + review.name + .replace(/[^a-zA-Z0-9_\- ]+/g, "") + .replace(/\s+/g, "-") + .slice(0, 60) + .trim() || "analyse"; + + return new Response(csv, { + status: 200, + headers: { + "Content-Type": "text/csv; charset=utf-8", + "Content-Disposition": `attachment; filename="${safeName}.csv"`, + "Cache-Control": "no-store", + }, + }); +} + +const STATUS_LABEL: Record = { + pending: "En attente", + running: "En cours", + ok: "OK", + error: "Erreur", +}; + +/** Échappement CSV : entoure de guillemets si séparateur/guillemet/saut de + * ligne, double les guillemets internes. Préfixe d'une apostrophe les valeurs + * commençant par =,+,-,@,tab,CR pour neutraliser l'injection de formule + * (les valeurs viennent de l'extraction LLM / des noms de fichiers et sont + * ouvertes dans Excel/LibreOffice). */ +function csvCell(v: string): string { + let s = v ?? ""; + if (/^[=+\-@\t\r]/.test(s)) s = `'${s}`; + if (/[";\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`; + return s; +} diff --git a/src/app/global-error.tsx b/src/app/global-error.tsx new file mode 100644 index 0000000..9bbbea1 --- /dev/null +++ b/src/app/global-error.tsx @@ -0,0 +1,54 @@ +"use client"; + +/** + * Dernier filet : erreur dans le root layout lui-même. Doit fournir ses + * propres / (il remplace le layout racine) et ne peut pas + * dépendre des styles applicatifs → styles inline minimaux. + */ +export default function GlobalError({ + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + +
    +

    Erreur critique

    +

    + L'application a rencontré une erreur inattendue. Rechargez la + page ; si le problème persiste, contactez l'administrateur. +

    + +
    + + + ); +} diff --git a/src/components/dropzone.tsx b/src/components/dropzone.tsx index faa0dee..6b04d46 100644 --- a/src/components/dropzone.tsx +++ b/src/components/dropzone.tsx @@ -30,9 +30,15 @@ export type UploadResult = | { ok: true; id: string; filename: string; sizeBytes: number } | { ok: false; error: string }; +/** Fichier refusé au drop, avec la raison — surfacé au caller (H16). */ +export type RejectedFile = { name: string; reason: "type" | "size" }; + export async function uploadDocument( file: File, - opts: { folderId?: string | null; signal?: AbortSignal } = {} + opts: { + folderId?: string | null; + signal?: AbortSignal; + } = {} ): Promise { const form = new FormData(); form.append("file", file); @@ -60,6 +66,9 @@ export async function uploadDocument( type DropzoneProps = { children: ReactNode; onFiles: (files: File[]) => void; + /** Appelé avec les fichiers refusés (type/taille) — pour ne plus les + * ignorer silencieusement (H16). */ + onRejected?: (files: RejectedFile[]) => void; /** Liste de types MIME ou préfixes (ex: "text/"). */ accept?: string[]; maxBytes?: number; @@ -77,13 +86,13 @@ type DropzoneProps = { * pour éviter le flicker sur les enfants, et filtre les fichiers via accept + * maxBytes avant de remonter au consommateur. * - * Les fichiers rejetés sont silencieusement ignorés — c'est au caller - * d'afficher un retour (compter `accepted.length` vs files reçus côté - * `onDrop` au besoin). + * Les fichiers rejetés (type non supporté / trop volumineux) sont remontés + * via `onRejected` pour que le caller affiche un retour explicite (H16). */ export function Dropzone({ children, onFiles, + onRejected, accept = DEFAULT_ACCEPTED_TYPES, maxBytes = DEFAULT_MAX_BYTES, disabled = false, @@ -135,12 +144,17 @@ export function Dropzone({ e.preventDefault(); enterCount.current = 0; setActive(false); - const files = Array.from(e.dataTransfer.files).filter( - (f) => isAccepted(f, accept) && f.size <= maxBytes - ); - if (files.length > 0) onFiles(files); + const accepted: File[] = []; + const rejected: RejectedFile[] = []; + for (const f of Array.from(e.dataTransfer.files)) { + if (!isAccepted(f, accept)) rejected.push({ name: f.name, reason: "type" }); + else if (f.size > maxBytes) rejected.push({ name: f.name, reason: "size" }); + else accepted.push(f); + } + if (rejected.length > 0) onRejected?.(rejected); + if (accepted.length > 0) onFiles(accepted); }, - [disabled, hasFiles, accept, maxBytes, onFiles] + [disabled, hasFiles, accept, maxBytes, onFiles, onRejected] ); return ( @@ -151,6 +165,9 @@ export function Dropzone({ onDrop={handleDrop} className={`relative ${className ?? ""}`} > + + {active ? "Déposez les fichiers pour les téléverser" : ""} + {children} {active && (
    +

    {title}

    + {children && ( +
    + {children} +
    + )} + {action &&
    {action}
    } +
    + ); +} diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 5964acf..308526d 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -40,11 +40,14 @@ function CommandDialog({ }) { return ( - - {title} - {description} - + {/* H27 : Title/Description DOIVENT être descendants de DialogContent + pour que Radix câble aria-labelledby (sinon dialog sans nom + accessible + warning console). */} + + {title} + {description} + {children} diff --git a/src/db/schema/documents.ts b/src/db/schema/documents.ts index 434057a..b1a3223 100644 --- a/src/db/schema/documents.ts +++ b/src/db/schema/documents.ts @@ -37,7 +37,8 @@ export const documents = pgTable("documents", { // disponible côté serveur (auquel cas on retombe sur mammoth HTML). previewStorageKey: text("preview_storage_key"), // Extracted plain text — capped at ~500KB to stay within typical LLM context. - // Larger documents would need RAG (chunking + embeddings), not implemented yet. + // Alimente l'injection en prompt système ET le RAG (chunking + embeddings + + // pgvector), en production — cf. lib/rag/*. extractedText: text("extracted_text"), extractionStatus: text("extraction_status").notNull().default("pending"), extractionError: text("extraction_error"), diff --git a/src/db/schema/index.ts b/src/db/schema/index.ts index fdb4a84..e08e946 100644 --- a/src/db/schema/index.ts +++ b/src/db/schema/index.ts @@ -3,6 +3,7 @@ export * from "./projects"; export * from "./provider-keys"; export * from "./conversations"; export * from "./messages"; +export * from "./message-chunks"; export * from "./connector-keys"; export * from "./document-folders"; export * from "./documents"; diff --git a/src/db/schema/message-chunks.ts b/src/db/schema/message-chunks.ts new file mode 100644 index 0000000..daadc5a --- /dev/null +++ b/src/db/schema/message-chunks.ts @@ -0,0 +1,41 @@ +import { + pgTable, + uuid, + text, + integer, + timestamp, + index, + vector, +} from "drizzle-orm/pg-core"; +import { messages } from "./messages"; +import { EMBEDDING_DIM } from "./document-chunks"; + +/** + * Embeddings des messages de conversation — alimente le RAG sur l'historique + * d'un projet (cf. lib/rag/message-search.ts). Mêmes dimensions et même + * index HNSW que document_chunks : seules les conversations rattachées à un + * projet sont indexées (maîtrise du coût d'embedding). + */ +export const messageChunks = pgTable( + "message_chunks", + { + id: uuid("id").defaultRandom().primaryKey(), + messageId: uuid("message_id") + .notNull() + .references(() => messages.id, { onDelete: "cascade" }), + chunkIndex: integer("chunk_index").notNull(), + content: text("content").notNull(), + embedding: vector("embedding", { dimensions: EMBEDDING_DIM }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (t) => [ + index("message_chunks_embedding_idx").using( + "hnsw", + t.embedding.op("vector_cosine_ops") + ), + index("message_chunks_message_idx").on(t.messageId), + ] +); + +export type MessageChunk = typeof messageChunks.$inferSelect; +export type NewMessageChunk = typeof messageChunks.$inferInsert; diff --git a/src/db/schema/messages.ts b/src/db/schema/messages.ts index f29ce08..16d66ac 100644 --- a/src/db/schema/messages.ts +++ b/src/db/schema/messages.ts @@ -33,7 +33,11 @@ export type SavedPart = toolCallId: string; toolName: string; output: unknown; - }; + } + // Trail d'audit multi-agents (events/outputs/retries) + skills détectées, + // persistés pour survivre au reload (theatre, badges, pills skills). Le + // `dataType` est le type du data part AI SDK (ex. "data-agent-event"). + | { type: "data"; dataType: string; data: unknown }; export const messages = pgTable("messages", { id: uuid("id").defaultRandom().primaryKey(), diff --git a/src/db/schema/pipelines.ts b/src/db/schema/pipelines.ts index 06feb68..cc6e3d3 100644 --- a/src/db/schema/pipelines.ts +++ b/src/db/schema/pipelines.ts @@ -33,6 +33,24 @@ import { messages } from "./messages"; */ export type PipelineMode = "sequential" | "council" | "parallel"; +/** + * Portée documentaire RAG d'un agent (Board). `null` en base = `inherit` = + * comportement historique (l'agent voit le périmètre documentaire de la + * conversation). Les autres modes restreignent ce que CET agent peut lire : + * - `none` : aucune pièce (l'agent travaille sans RAG documentaire) + * - `project` : tout le périmètre projet (explicite, = inherit en conv. projet) + * - `folders` : sous-arbres de dossiers choisis (intersection avec le projet) + * - `documents` : documents explicites (intersection avec le projet) + * Règle de sécurité : la portée d'un agent est TOUJOURS une intersection avec + * le périmètre de la conversation, jamais une extension (cf. resolveAgentRag). + */ +export type AgentRagScope = + | { mode: "inherit" } + | { mode: "none" } + | { mode: "project" } + | { mode: "folders"; folderIds: string[] } + | { mode: "documents"; documentIds: string[] }; + export const pipelines = pgTable( "pipelines", { @@ -82,6 +100,11 @@ export const pipelineAgents = pgTable( modelOverride: text("model_override"), systemPrompt: text("system_prompt"), toolAllowlist: jsonb("tool_allowlist").$type(), + // Portée documentaire RAG propre à cet agent. NULL = inherit (périmètre de + // la conversation). Cf. AgentRagScope + resolveAgentRag. + ragScope: jsonb("rag_scope").$type(), + // Température d'échantillonnage propre à l'agent. NULL = défaut du provider. + temperature: doublePrecision("temperature"), position: integer("position").notNull().default(0), // Coordonnées custom sur le canvas React Flow. NULL = layout // automatique (calculé selon le mode du pipeline). Dès que l'user diff --git a/src/db/schema/projects.ts b/src/db/schema/projects.ts index 045ab7b..158c68d 100644 --- a/src/db/schema/projects.ts +++ b/src/db/schema/projects.ts @@ -1,11 +1,26 @@ -import { pgTable, uuid, text, timestamp } from "drizzle-orm/pg-core"; +import { + pgTable, + uuid, + text, + timestamp, + type AnyPgColumn, +} from "drizzle-orm/pg-core"; import { users } from "./users"; +import { documentFolders } from "./document-folders"; export const projects = pgTable("projects", { id: uuid("id").defaultRandom().primaryKey(), userId: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), + // Dossier-racine du projet dans l'arbre /documents. Les documents du + // projet = ceux rangés dans ce dossier ou un de ses sous-dossiers + // (cf. lib/projects/scope.ts). Source de vérité de l'appartenance + // documentaire — documents.projectId n'est plus utilisé pour ça. + folderId: uuid("folder_id").references( + (): AnyPgColumn => documentFolders.id, + { onDelete: "set null" } + ), name: text("name").notNull(), description: text("description"), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/src/lib/ai/saved-parts.ts b/src/lib/ai/saved-parts.ts index 242aefc..ac5718a 100644 --- a/src/lib/ai/saved-parts.ts +++ b/src/lib/ai/saved-parts.ts @@ -33,6 +33,10 @@ export function uiPartsFromSaved( input: p.input, output: result?.output, } as never); + } else if (p.type === "data") { + // Ré-émet le data part tel que les consommateurs (theatre, badges, + // skills) l'attendent : { type: "data-agent-event", data }. + out.push({ type: p.dataType, data: p.data } as never); } // tool-result : déjà pairé avec son tool-call ci-dessus, on saute. } diff --git a/src/lib/audit/labels.ts b/src/lib/audit/labels.ts new file mode 100644 index 0000000..c273d13 --- /dev/null +++ b/src/lib/audit/labels.ts @@ -0,0 +1,35 @@ +/** + * Libellés FR des actions du journal d'audit. Centralisé ici pour être + * cohérent entre la page /admin/audit et la fiche utilisateur (avant, la + * fiche affichait le slug brut). Toute action émise via recordAudit devrait + * avoir une entrée — à défaut, on retombe sur le slug (lisible mais brut). + */ +export const ACTION_LABEL: Record = { + "user.create": "Utilisateur créé", + "user.update": "Utilisateur modifié", + "user.disable": "Utilisateur désactivé", + "user.enable": "Utilisateur réactivé", + "user.delete": "Utilisateur supprimé", + "user.role": "Rôle modifié", + "user.password.reset": "Mot de passe réinitialisé", + "user.quota.update": "Quota modifié", + "provider.add": "Clé provider ajoutée", + "provider.delete": "Clé provider supprimée", + "provider.toggle": "Clé provider activée/désactivée", + "connector.add": "Connecteur ajouté", + "connector.delete": "Connecteur supprimé", + "doc.delete": "Document supprimé", + "cabinet.update": "Configuration cabinet modifiée", + "auth.login": "Connexion", + "auth.login.failed": "Échec de connexion", +}; + +export function labelForAction(action: string): string { + return ACTION_LABEL[action] ?? action; +} + +/** Options pour le filtre par action (triées par libellé). */ +export const AUDIT_ACTION_OPTIONS: { value: string; label: string }[] = + Object.entries(ACTION_LABEL) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label, "fr")); diff --git a/src/lib/connectors/catalog.ts b/src/lib/connectors/catalog.ts index ef97ceb..4c628bc 100644 --- a/src/lib/connectors/catalog.ts +++ b/src/lib/connectors/catalog.ts @@ -22,6 +22,9 @@ export type ConnectorMeta = { category: ConnectorCategory; /** APIs unlocked by configuring this connector. Surfaced in the UI. */ unlocks: string[]; + /** Sources annoncées mais pas encore implémentées (affichées « à venir »). + * Honnêteté : on ne liste comme « débloqué » que ce qui marche vraiment. */ + comingSoon?: string[]; credentialFields: CredentialField[]; /** SVG logo path under /public, used in CutoutCard media. */ logo: string; @@ -40,7 +43,11 @@ export const CONNECTOR_CATALOG: Record = { icon: IconScale, docsUrl: "https://piste.gouv.fr/", category: "official", - unlocks: ["Légifrance", "Judilibre", "JADE", "INPI", "BODACC"], + // Seul Légifrance est réellement câblé (lib/connectors/tools.ts). Les + // autres sous-APIs PISTE sont annoncées « à venir » plutôt que prétendues + // débloquées. + unlocks: ["Légifrance"], + comingSoon: ["Judilibre", "JADE", "INPI", "BODACC"], credentialFields: [ { name: "client_id", diff --git a/src/lib/connectors/pappers.ts b/src/lib/connectors/pappers.ts index 335e245..63a40c2 100644 --- a/src/lib/connectors/pappers.ts +++ b/src/lib/connectors/pappers.ts @@ -108,6 +108,20 @@ export async function pappersSearch( }); } +/** + * R5 : teste le token Pappers via une recherche minimale. Consomme un appel + * API réel (facturable selon le plan) — explicite et rare. + */ +export async function testPappersConnection( + userId: string +): Promise<"ok" | "auth_error" | "config_error" | "network_error"> { + const r = await pappersSearch(userId, "test"); + if (r.ok) return "ok"; + if (r.reason === "auth") return "auth_error"; + if (r.reason === "config") return "config_error"; + return "network_error"; +} + export async function pappersGet( userId: string, siren: string diff --git a/src/lib/connectors/piste.ts b/src/lib/connectors/piste.ts index f626c75..87bd4ae 100644 --- a/src/lib/connectors/piste.ts +++ b/src/lib/connectors/piste.ts @@ -66,6 +66,27 @@ async function getToken(userId: string): Promise> { return toolOk(data.access_token); } +export type ConnectorTestStatus = + | "ok" + | "auth_error" + | "config_error" + | "network_error"; + +/** + * R5 : teste les identifiants PISTE en forçant un échange OAuth frais. + * Consomme un appel OAuth réel (rare, explicite). + */ +export async function testPisteConnection( + userId: string +): Promise { + tokenCache.delete(userId); + const r = await getToken(userId); + if (r.ok) return "ok"; + if (r.reason === "auth") return "auth_error"; + if (r.reason === "config") return "config_error"; + return "network_error"; +} + async function pisteRequest( userId: string, path: string, diff --git a/src/lib/connectors/tools.ts b/src/lib/connectors/tools.ts index c57eedd..813f872 100644 --- a/src/lib/connectors/tools.ts +++ b/src/lib/connectors/tools.ts @@ -4,8 +4,9 @@ import { pappersSearch, pappersGet } from "./pappers"; import { legifranceSearch } from "./piste"; import { listActiveConnectorTypes } from "./runtime"; import { ragSearch } from "@/lib/rag/search"; +import { searchProjectMessages } from "@/lib/rag/message-search"; import { NoEmbeddingProviderError } from "@/lib/rag/embed"; -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; import { documentChunks, documents, providerKeys } from "@/db/schema"; import { runTool, toolError, toolOk } from "@/lib/tools/result"; @@ -16,19 +17,49 @@ import { } from "@/lib/docgen/docx-tracked"; import { getObjectBytes } from "@/lib/storage"; +/** + * Périmètre projet (modèle dossier = projet). Quand fourni, les outils + * documentaires ne voient QUE les documents du projet, les documents + * générés/édités atterrissent dans son dossier, et l'outil de recherche + * dans l'historique des conversations du projet est activé. + */ +export type ToolScope = { + projectId: string; + /** Conversation courante — exclue de la recherche dans l'historique. */ + conversationId: string; + /** Documents du projet (sous-arbre du dossier-racine). Peut être vide. */ + documentIds: string[]; + /** Dossier-racine — destination des documents générés/édités. */ + folderId: string | null; +}; + /** * Build the set of AI SDK tools available for `userId`, based on which * connectors they have active. Returns an empty object when no connector * is configured — streamText() then runs without tool calling. * + * Quand `scope` est fourni (conversation rattachée à un projet), les outils + * documentaires sont restreints aux documents du projet — l'IA « ne prend en + * compte en RAG que les documents du projet ». + * * Tool executions never throw: they return a `{ ok, ... }` envelope so the * model can relay a precise error message to the user instead of choking on * an opaque "tool execution failed". */ -export async function buildToolsForUser(userId: string): Promise { +export async function buildToolsForUser( + userId: string, + scope?: ToolScope +): Promise { const active = await listActiveConnectorTypes(userId); const tools: ToolSet = {}; + // Scoping projet : `scoped` distingue le mode projet (documentIds est une + // liste, éventuellement vide) du mode global (scope absent → tous les docs). + const scoped = scope != null; + const scopedDocIds = scope?.documentIds ?? []; + const scopedDocSet = new Set(scopedDocIds); + const generatedDocFolderId = scope?.folderId ?? null; + // search_documents : disponible si l'utilisateur a au moins un chunk // indexé ET une clé Mistral active (requise pour embedder la requête). const hasMistral = await db @@ -44,8 +75,28 @@ export async function buildToolsForUser(userId: string): Promise { .limit(1); if (hasMistral.length > 0) { - const chunkCount = await db.$count(documentChunks); - if (chunkCount > 0) { + // R9 : comptage SCOPÉ à l'utilisateur (et au projet si scope). Un + // `$count(documentChunks)` global proposait search_documents à un user + // sans aucun document dès qu'un AUTRE tenant avait indexé quelque chose + // (fuite de disponibilité cross-tenant → réponses « aucun résultat » + // déroutantes). En mode projet sans document, l'outil n'est pas proposé. + const hasChunks = + scoped && scopedDocIds.length === 0 + ? [] + : await db + .select({ documentId: documentChunks.documentId }) + .from(documentChunks) + .innerJoin(documents, eq(documents.id, documentChunks.documentId)) + .where( + scoped + ? and( + eq(documents.userId, userId), + inArray(documentChunks.documentId, scopedDocIds) + ) + : eq(documents.userId, userId) + ) + .limit(1); + if (hasChunks.length > 0) { tools.search_documents = tool({ description: "Recherche sémantique dans les documents importés par l'utilisateur. Renvoie les passages les plus pertinents avec leur nom de fichier source. Préférez ce tool dès que la question porte sur le contenu d'un document précis, un contrat, un mémo, etc.", @@ -59,8 +110,14 @@ export async function buildToolsForUser(userId: string): Promise { }), execute: async ({ query }) => runTool(async () => { + // En contexte projet sans document, on ne retombe PAS sur la + // recherche globale (ragSearch ignore un documentIds vide) : + // on renvoie explicitement aucun résultat. + if (scoped && scopedDocIds.length === 0) return toolOk([]); try { - const hits = await ragSearch(userId, query); + const hits = await ragSearch(userId, query, { + documentIds: scoped ? scopedDocIds : undefined, + }); return toolOk( hits.map((h) => ({ filename: h.filename, @@ -81,6 +138,52 @@ export async function buildToolsForUser(userId: string): Promise { }), }); } + + // Recherche dans l'historique des conversations du projet — disponible + // dès qu'on est en contexte projet, indépendamment des documents. + if (scope) { + const activeScope = scope; + tools.search_conversation_history = tool({ + description: + "Recherche sémantique dans l'historique des CONVERSATIONS passées de ce projet (hors conversation courante). Utilisez-le pour retrouver une décision, une analyse ou un échange antérieur avec l'utilisateur sur ce dossier.", + inputSchema: z.object({ + query: z + .string() + .min(2) + .describe( + "Question ou termes-clés. Sera traduite en embedding vectoriel." + ), + }), + execute: async ({ query }) => + runTool(async () => { + try { + const hits = await searchProjectMessages( + userId, + activeScope.projectId, + query, + { excludeConversationId: activeScope.conversationId } + ); + return toolOk( + hits.map((h) => ({ + conversation: h.conversationTitle, + role: h.role, + date: h.createdAt.toISOString().slice(0, 10), + content: h.content, + similarity: Math.round(h.similarity * 100) / 100, + })) + ); + } catch (err) { + if (err instanceof NoEmbeddingProviderError) { + return toolError( + "config", + "La recherche dans l'historique nécessite une clé Mistral active. Activez-la dans /providers." + ); + } + throw err; + } + }), + }); + } } if (active.includes("piste")) { @@ -206,6 +309,7 @@ export async function buildToolsForUser(userId: string): Promise { format, spec: { ...rest, sections }, userId, + folderId: generatedDocFolderId, }); return toolOk({ document_id: result.documentId, @@ -230,6 +334,7 @@ export async function buildToolsForUser(userId: string): Promise { inputSchema: z.object({}), execute: async () => runTool(async () => { + if (scoped && scopedDocIds.length === 0) return toolOk([]); const rows = await db .select({ id: documents.id, @@ -239,7 +344,14 @@ export async function buildToolsForUser(userId: string): Promise { version: documents.version, }) .from(documents) - .where(eq(documents.userId, userId)) + .where( + scoped + ? and( + eq(documents.userId, userId), + inArray(documents.id, scopedDocIds) + ) + : eq(documents.userId, userId) + ) .orderBy(desc(documents.createdAt)) .limit(50); return toolOk( @@ -276,6 +388,12 @@ export async function buildToolsForUser(userId: string): Promise { }), execute: async ({ document_id, max_chars }) => runTool(async () => { + if (scoped && !scopedDocSet.has(document_id)) { + return toolError( + "validation", + "Ce document n'appartient pas au projet courant." + ); + } const [doc] = await db .select({ id: documents.id, @@ -336,6 +454,12 @@ export async function buildToolsForUser(userId: string): Promise { }), execute: async ({ document_id, needle }) => runTool(async () => { + if (scoped && !scopedDocSet.has(document_id)) { + return toolError( + "validation", + "Ce document n'appartient pas au projet courant." + ); + } const [doc] = await db .select({ extractedText: documents.extractedText, @@ -431,6 +555,12 @@ export async function buildToolsForUser(userId: string): Promise { }), execute: async ({ document_id, edits }) => runTool(async () => { + if (scoped && !scopedDocSet.has(document_id)) { + return toolError( + "validation", + "Ce document n'appartient pas au projet courant." + ); + } const [doc] = await db .select({ id: documents.id, @@ -466,6 +596,7 @@ export async function buildToolsForUser(userId: string): Promise { "application/vnd.openxmlformats-officedocument.wordprocessingml.document", filename: `${baseName} (édité par Louis).docx`, userId, + folderId: generatedDocFolderId, }); return toolOk({ diff --git a/src/lib/diff/line-diff.test.ts b/src/lib/diff/line-diff.test.ts new file mode 100644 index 0000000..28d46fb --- /dev/null +++ b/src/lib/diff/line-diff.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "vitest"; +import { diffLines, diffStats, collapseDiff, MAX_DP_LINES } from "./line-diff"; + +describe("diffLines", () => { + it("renvoie tout en « eq » pour deux textes identiques", () => { + const { ops, truncated } = diffLines("a\nb\nc", "a\nb\nc"); + expect(truncated).toBe(false); + expect(ops.every((o) => o.type === "eq")).toBe(true); + expect(ops.map((o) => o.text)).toEqual(["a", "b", "c"]); + }); + + it("détecte une ligne ajoutée au milieu", () => { + const { ops } = diffLines("a\nc", "a\nb\nc"); + expect(ops).toEqual([ + { type: "eq", text: "a" }, + { type: "add", text: "b" }, + { type: "eq", text: "c" }, + ]); + }); + + it("détecte une ligne supprimée au milieu", () => { + const { ops } = diffLines("a\nb\nc", "a\nc"); + expect(ops).toEqual([ + { type: "eq", text: "a" }, + { type: "del", text: "b" }, + { type: "eq", text: "c" }, + ]); + }); + + it("traite une modification comme suppression + ajout", () => { + const { ops } = diffLines("titre\nancien\nfin", "titre\nnouveau\nfin"); + expect(ops).toContainEqual({ type: "del", text: "ancien" }); + expect(ops).toContainEqual({ type: "add", text: "nouveau" }); + // le préfixe et le suffixe communs restent « eq » + expect(ops[0]).toEqual({ type: "eq", text: "titre" }); + expect(ops[ops.length - 1]).toEqual({ type: "eq", text: "fin" }); + }); + + it("ajout pur à partir d'un texte vide", () => { + const { ops } = diffLines("", "x\ny"); + // "" → [""] côté ancien ; "" est préfixe commun avec "" du nouveau split ? + // Le nouveau "x\ny" → ["x","y"], l'ancien "" → [""]. Pas de préfixe commun. + expect(diffStats(ops).added).toBeGreaterThanOrEqual(2); + expect(diffStats(ops).removed).toBeLessThanOrEqual(1); + }); + + it("normalise les fins de ligne CRLF", () => { + const { ops } = diffLines("a\r\nb", "a\nb"); + expect(ops.every((o) => o.type === "eq")).toBe(true); + }); + + it("préserve l'ordre : préfixe, divergence, suffixe", () => { + const { ops } = diffLines("h1\nh2\nold\nf1", "h1\nh2\nnew1\nnew2\nf1"); + const texts = ops.map((o) => `${o.type}:${o.text}`); + expect(texts[0]).toBe("eq:h1"); + expect(texts[1]).toBe("eq:h2"); + expect(texts[texts.length - 1]).toBe("eq:f1"); + expect(texts).toContain("del:old"); + expect(texts).toContain("add:new1"); + expect(texts).toContain("add:new2"); + }); + + it("tronque (bloc remplacé) au-delà du plafond DP", () => { + // Région divergente > MAX_DP_LINES de chaque côté, sans préfixe/suffixe commun. + const old = Array.from({ length: MAX_DP_LINES + 50 }, (_, i) => `o${i}`).join( + "\n" + ); + const next = Array.from( + { length: MAX_DP_LINES + 50 }, + (_, i) => `n${i}` + ).join("\n"); + const { ops, truncated } = diffLines(old, next); + expect(truncated).toBe(true); + const stats = diffStats(ops); + expect(stats.removed).toBe(MAX_DP_LINES + 50); + expect(stats.added).toBe(MAX_DP_LINES + 50); + }); + + it("le trim préfixe/suffixe évite la troncature même sur un gros document", () => { + // Énorme corps identique, une seule ligne modifiée → DP minuscule. + const body = Array.from({ length: 5000 }, (_, i) => `ligne ${i}`); + const old = [...body, "AVANT", ...body].join("\n"); + const next = [...body, "APRÈS", ...body].join("\n"); + const { ops, truncated } = diffLines(old, next); + expect(truncated).toBe(false); + expect(ops).toContainEqual({ type: "del", text: "AVANT" }); + expect(ops).toContainEqual({ type: "add", text: "APRÈS" }); + }); +}); + +describe("collapseDiff", () => { + it("replie les plages identiques lointaines en gap, garde le contexte", () => { + const body = Array.from({ length: 20 }, (_, i) => `l${i}`); + const { ops } = diffLines( + body.join("\n"), + [...body.slice(0, 10), "INSÉRÉ", ...body.slice(10)].join("\n") + ); + const display = collapseDiff(ops, 2); + // un gap au début (avant le contexte) et un à la fin + expect(display.some((o) => o.type === "gap")).toBe(true); + // le changement et son contexte immédiat sont conservés + expect(display).toContainEqual({ type: "add", text: "INSÉRÉ" }); + // pas de plage « eq » de plus de `context` lignes consécutives hors gap + const eqRun = display.filter((o) => o.type === "eq").length; + expect(eqRun).toBeLessThanOrEqual(4 + 1); // 2 de contexte de chaque côté + }); + + it("ne crée pas de gap quand tout est proche d'un changement", () => { + const { ops } = diffLines("a\nb", "a\nc"); + const display = collapseDiff(ops, 3); + expect(display.some((o) => o.type === "gap")).toBe(false); + }); +}); + +describe("diffStats", () => { + it("compte les ajouts et suppressions", () => { + expect( + diffStats([ + { type: "eq", text: "a" }, + { type: "add", text: "b" }, + { type: "add", text: "c" }, + { type: "del", text: "d" }, + ]) + ).toEqual({ added: 2, removed: 1 }); + }); +}); diff --git a/src/lib/diff/line-diff.ts b/src/lib/diff/line-diff.ts new file mode 100644 index 0000000..cd10bf3 --- /dev/null +++ b/src/lib/diff/line-diff.ts @@ -0,0 +1,158 @@ +/** + * Diff ligne-à-ligne « maison » (H19) pour comparer deux versions du texte + * extrait d'un document. Pas de dépendance externe : un LCS classique, mais + * encadré pour rester sûr sur de gros documents juridiques. + * + * Stratégie : + * 1. On retire le préfixe et le suffixe communs (cas le plus fréquent : une + * révision ne touche qu'un passage — inutile de comparer tout le reste). + * 2. Sur la région divergente restante, on calcule le LCS en programmation + * dynamique, **plafonné** : au-delà de MAX_DP_LINES lignes de part et + * d'autre, la table O(n·m) deviendrait trop coûteuse — on retombe alors sur + * un « bloc remplacé » (tout l'ancien supprimé, tout le nouveau ajouté) en + * signalant la troncature à l'appelant. + */ + +export type DiffOpType = "eq" | "add" | "del"; +export type DiffOp = { type: DiffOpType; text: string }; + +/** Plafond de lignes pour la région comparée en DP (chaque côté). */ +export const MAX_DP_LINES = 1500; + +function splitLines(text: string): string[] { + return text.replace(/\r\n?/g, "\n").split("\n"); +} + +export function diffLines( + oldText: string, + newText: string +): { ops: DiffOp[]; truncated: boolean } { + const a = splitLines(oldText); + const b = splitLines(newText); + + const prefix: DiffOp[] = []; + let start = 0; + while (start < a.length && start < b.length && a[start] === b[start]) { + prefix.push({ type: "eq", text: a[start] }); + start++; + } + + const suffix: DiffOp[] = []; + let endA = a.length - 1; + let endB = b.length - 1; + while (endA >= start && endB >= start && a[endA] === b[endB]) { + suffix.push({ type: "eq", text: a[endA] }); + endA--; + endB--; + } + suffix.reverse(); + + const midA = a.slice(start, endA + 1); + const midB = b.slice(start, endB + 1); + + const ops: DiffOp[] = [...prefix]; + let truncated = false; + + if (midA.length > MAX_DP_LINES || midB.length > MAX_DP_LINES) { + truncated = true; + for (const line of midA) ops.push({ type: "del", text: line }); + for (const line of midB) ops.push({ type: "add", text: line }); + } else { + ops.push(...lcsDiff(midA, midB)); + } + + ops.push(...suffix); + return { ops, truncated }; +} + +function lcsDiff(a: string[], b: string[]): DiffOp[] { + const n = a.length; + const m = b.length; + if (n === 0 && m === 0) return []; + if (n === 0) return b.map((text) => ({ type: "add" as const, text })); + if (m === 0) return a.map((text) => ({ type: "del" as const, text })); + + const width = m + 1; + // Longueurs LCS ≤ min(n, m) ≤ MAX_DP_LINES, donc Uint16 suffit largement. + const dp = new Uint16Array((n + 1) * width); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + const here = i * width + j; + if (a[i] === b[j]) { + dp[here] = dp[(i + 1) * width + (j + 1)] + 1; + } else { + const down = dp[(i + 1) * width + j]; + const right = dp[i * width + (j + 1)]; + dp[here] = down >= right ? down : right; + } + } + } + + const ops: DiffOp[] = []; + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j]) { + ops.push({ type: "eq", text: a[i] }); + i++; + j++; + } else if (dp[(i + 1) * width + j] >= dp[i * width + (j + 1)]) { + ops.push({ type: "del", text: a[i] }); + i++; + } else { + ops.push({ type: "add", text: b[j] }); + j++; + } + } + while (i < n) ops.push({ type: "del", text: a[i++] }); + while (j < m) ops.push({ type: "add", text: b[j++] }); + return ops; +} + +export type GapOp = { type: "gap"; count: number }; +export type DisplayOp = DiffOp | GapOp; + +/** + * Replie les longues plages identiques en marqueurs « gap » (façon diff + * unifié) : on ne garde que `context` lignes inchangées de part et d'autre de + * chaque changement. Garde le rendu lisible ET le payload borné (sur une + * révision typique, l'essentiel du texte est identique). + */ +export function collapseDiff(ops: DiffOp[], context = 3): DisplayOp[] { + const keep = new Array(ops.length).fill(false); + for (let i = 0; i < ops.length; i++) { + if (ops[i].type !== "eq") { + keep[i] = true; + for (let k = 1; k <= context; k++) { + if (i - k >= 0) keep[i - k] = true; + if (i + k < ops.length) keep[i + k] = true; + } + } + } + const out: DisplayOp[] = []; + let gap = 0; + for (let i = 0; i < ops.length; i++) { + if (keep[i]) { + if (gap > 0) { + out.push({ type: "gap", count: gap }); + gap = 0; + } + out.push(ops[i]); + } else { + gap++; + } + } + if (gap > 0) out.push({ type: "gap", count: gap }); + return out; +} + +/** Compteur de lignes ajoutées / supprimées, pour un résumé « +x / −y ». */ +export function diffStats(ops: DiffOp[]): { added: number; removed: number } { + let added = 0; + let removed = 0; + for (const op of ops) { + if (op.type === "add") added++; + else if (op.type === "del") removed++; + } + return { added, removed }; +} diff --git a/src/lib/docgen/index.ts b/src/lib/docgen/index.ts index d73eb5d..88e8c65 100644 --- a/src/lib/docgen/index.ts +++ b/src/lib/docgen/index.ts @@ -31,11 +31,13 @@ export async function generateAndStore({ spec, userId, projectId, + folderId, }: { format: DocFormat; spec: DocumentSpec; userId: string; projectId?: string | null; + folderId?: string | null; }): Promise<{ documentId: string; filename: string; format: DocFormat }> { const buffer = format === "docx" ? await generateDocx(spec) : await generatePdf(spec); @@ -54,6 +56,7 @@ export async function generateAndStore({ filename, userId, projectId, + folderId, }); } @@ -68,12 +71,14 @@ export async function storeBuffer({ filename, userId, projectId, + folderId, }: { buffer: Buffer; contentType: string; filename: string; userId: string; projectId?: string | null; + folderId?: string | null; }): Promise<{ documentId: string; filename: string; format: DocFormat }> { const baseKey = `${userId}/louis-generated/${nanoid()}-${filename}`; await uploadObject(baseKey, buffer, contentType); @@ -116,6 +121,7 @@ export async function storeBuffer({ .values({ userId, projectId: projectId ?? null, + folderId: folderId ?? null, filename, contentType, sizeBytes: buffer.length, diff --git a/src/lib/extract.ts b/src/lib/extract.ts index 066fe9a..33908a4 100644 --- a/src/lib/extract.ts +++ b/src/lib/extract.ts @@ -2,12 +2,34 @@ * Server-side text extraction from PDF and DOCX documents. * * We cap extracted text at ~500K characters to stay within typical LLM - * context windows. Larger documents will need a separate RAG pipeline - * (chunking + embeddings + vector search) — not implemented in v0.1. + * context windows. Le texte extrait alimente à la fois l'injection en + * prompt système (petits fichiers) ET le pipeline RAG (chunking + embeddings + * + recherche vectorielle pgvector), qui EST en production (cf. lib/rag/*). */ const MAX_TEXT_LENGTH = 500_000; +/** + * En-dessous de ce nombre de caractères « utiles », un PDF est considéré + * comme scanné (image sans couche texte). Seuil bas pour minimiser les faux + * positifs sur de très courts documents. + */ +const SCANNED_PDF_MIN_CHARS = 20; + +/** + * Levée quand un PDF ne contient aucune couche texte exploitable (scanné). + * Le message remonte tel quel dans documents.extractionError pour expliquer + * à l'utilisateur pourquoi le document n'est ni indexé ni interrogeable. + */ +export class ScannedPdfError extends Error { + constructor() { + super( + "PDF probablement scanné (aucune couche texte détectée) — un OCR est requis pour l'indexer et l'interroger." + ); + this.name = "ScannedPdfError"; + } +} + export const PDF_MEDIA_TYPE = "application/pdf"; export const DOCX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -26,6 +48,12 @@ export async function extractText( if (contentType === PDF_MEDIA_TYPE) { raw = await extractPdf(buffer); + // H17 : un PDF scanné ressort quasi vide → on le signale explicitement + // plutôt que de l'enregistrer « ok » avec un texte vide (invisible RAG, + // inutilisable en analyse tabulaire, sans aucun message à l'utilisateur). + if (raw.trim().length < SCANNED_PDF_MIN_CHARS) { + throw new ScannedPdfError(); + } } else if (contentType === DOCX_MEDIA_TYPE) { raw = await extractDocx(buffer); } else if (contentType.startsWith("text/")) { diff --git a/src/lib/format/time.ts b/src/lib/format/time.ts new file mode 100644 index 0000000..935b3e1 --- /dev/null +++ b/src/lib/format/time.ts @@ -0,0 +1,22 @@ +/** + * Formatage de date relative en français — source UNIQUE (avant : deux copies + * divergentes dans dashboard et admin/users). `nullLabel` permet d'adapter le + * cas vide selon le contexte (« — » par défaut, « jamais utilisé » côté admin). + */ +export function formatRelativeFr( + d: Date | string | null | undefined, + nullLabel = "—" +): string { + if (!d) return nullLabel; + const date = typeof d === "string" ? new Date(d) : d; + const ms = Date.now() - date.getTime(); + const m = Math.floor(ms / 60_000); + if (m < 1) return "à l'instant"; + if (m < 60) return `il y a ${m} min`; + const h = Math.floor(m / 60); + if (h < 24) return `il y a ${h} h`; + const days = Math.floor(h / 24); + if (days < 30) return `il y a ${days} j`; + if (days < 365) return `il y a ${Math.floor(days / 30)} mois`; + return date.toLocaleDateString("fr-FR"); +} diff --git a/src/lib/navigation.ts b/src/lib/navigation.ts new file mode 100644 index 0000000..9297dba --- /dev/null +++ b/src/lib/navigation.ts @@ -0,0 +1,33 @@ +import type { ComponentType } from "react"; +import { + IconLayoutDashboard, + IconMessageCircle, + IconFolders, + IconFolder, + IconTable, + IconLibrary, + IconBriefcase, +} from "@tabler/icons-react"; + +export type NavItem = { + href: string; + label: string; + icon: ComponentType<{ className?: string }>; +}; + +/** + * Source UNIQUE de la navigation primaire — consommée par la barre latérale + * (sidebar-content) ET la palette de commandes (command-palette). Avant, chaque + * surface tenait sa propre copie : les renommages VOCAB (« Bureau » → « Board », + * etc.) devaient être appliqués à plusieurs endroits et finissaient par diverger + * en libellé, ordre et icône. Un seul tableau ici = plus de dérive possible. + */ +export const PRIMARY_NAV: readonly NavItem[] = [ + { href: "/dashboard", label: "Tableau de bord", icon: IconLayoutDashboard }, + { href: "/chat", label: "Conversations", icon: IconMessageCircle }, + { href: "/projects", label: "Projets", icon: IconFolders }, + { href: "/documents", label: "Documents", icon: IconFolder }, + { href: "/tabular-reviews", label: "Analyses tabulaires", icon: IconTable }, + { href: "/workflows", label: "Trames", icon: IconLibrary }, + { href: "/board", label: "Board", icon: IconBriefcase }, +]; diff --git a/src/lib/orchestrator/agents/agents.test.ts b/src/lib/orchestrator/agents/agents.test.ts index 168aa53..5f3befa 100644 --- a/src/lib/orchestrator/agents/agents.test.ts +++ b/src/lib/orchestrator/agents/agents.test.ts @@ -3,6 +3,8 @@ import { AGENT_REGISTRY, CitatorAgent, DefaultAgent, + DraftingAgent, + LegifranceAgent, OrchestratorAgent, ResearchAgent, ReviewerAgent, @@ -32,9 +34,12 @@ describe("Agent registry: résolution par rôle", () => { expect(resolveAgentConstructor("orchestrator")).toBe(OrchestratorAgent); }); - it("rôle inconnu (drafting/legifrance non implémentés) → undefined", () => { - expect(resolveAgentConstructor("drafting")).toBeUndefined(); - expect(resolveAgentConstructor("legifrance")).toBeUndefined(); + it("drafting → DraftingAgent (P6 : rôle désormais implémenté)", () => { + expect(resolveAgentConstructor("drafting")).toBe(DraftingAgent); + }); + + it("legifrance → LegifranceAgent (P6 : rôle désormais implémenté)", () => { + expect(resolveAgentConstructor("legifrance")).toBe(LegifranceAgent); }); it("AGENT_REGISTRY est cohérent avec les exports nommés", () => { @@ -43,6 +48,8 @@ describe("Agent registry: résolution par rôle", () => { expect(AGENT_REGISTRY.citator).toBe(CitatorAgent); expect(AGENT_REGISTRY.reviewer).toBe(ReviewerAgent); expect(AGENT_REGISTRY.orchestrator).toBe(OrchestratorAgent); + expect(AGENT_REGISTRY.drafting).toBe(DraftingAgent); + expect(AGENT_REGISTRY.legifrance).toBe(LegifranceAgent); }); }); diff --git a/src/lib/orchestrator/agents/base.ts b/src/lib/orchestrator/agents/base.ts index c9d3f60..fd3b9dc 100644 --- a/src/lib/orchestrator/agents/base.ts +++ b/src/lib/orchestrator/agents/base.ts @@ -9,6 +9,7 @@ import { loadProviderKey, modelFromKey } from "@/lib/providers/factory"; import { buildToolsForUser } from "@/lib/connectors/tools"; import { buildMcpToolsForUser } from "@/lib/mcp/tools"; import { composeSystem, filterTools } from "./default"; +import { resolveAgentRag, omitDocumentaryRagTools } from "./rag-scope"; import type { AgentContext, AgentDefinition, @@ -60,11 +61,17 @@ export async function runAgentStream( let tools: ToolSet = {}; if (allowlist === null || (allowlist && allowlist.length > 0)) { + const { scope, hideDocumentaryRag } = await resolveAgentRag( + ctx, + def.ragScope + ); const [connectorTools, mcpTools] = await Promise.all([ - buildToolsForUser(ctx.userId), + buildToolsForUser(ctx.userId, scope), buildMcpToolsForUser(ctx.userId), ]); - tools = filterTools({ ...connectorTools, ...mcpTools }, allowlist); + let merged: ToolSet = { ...connectorTools, ...mcpTools }; + if (hideDocumentaryRag) merged = omitDocumentaryRagTools(merged); + tools = filterTools(merged, allowlist); } const stopWhen: StopCondition = stepCountIs(defaults.maxSteps ?? 3); @@ -75,6 +82,8 @@ export async function runAgentStream( messages: modelMessages, tools, stopWhen, + temperature: def.temperature ?? undefined, + abortSignal: ctx.abortSignal, }); return { kind: "stream", stream }; diff --git a/src/lib/orchestrator/agents/default.ts b/src/lib/orchestrator/agents/default.ts index 4401b65..e413f18 100644 --- a/src/lib/orchestrator/agents/default.ts +++ b/src/lib/orchestrator/agents/default.ts @@ -7,6 +7,7 @@ import { import { loadProviderKey, modelFromKey } from "@/lib/providers/factory"; import { buildToolsForUser } from "@/lib/connectors/tools"; import { buildMcpToolsForUser } from "@/lib/mcp/tools"; +import { resolveAgentRag, omitDocumentaryRagTools } from "./rag-scope"; import type { Agent, AgentContext, @@ -89,14 +90,17 @@ export class DefaultAgent implements Agent { const system = composeSystem(DEFAULT_CHAT_SYSTEM_PROMPT, this.definition, ctx); + const { scope, hideDocumentaryRag } = await resolveAgentRag( + ctx, + this.definition.ragScope + ); const [connectorTools, mcpTools] = await Promise.all([ - buildToolsForUser(ctx.userId), + buildToolsForUser(ctx.userId, scope), buildMcpToolsForUser(ctx.userId), ]); - const tools = filterTools( - { ...connectorTools, ...mcpTools }, - this.definition.toolAllowlist - ); + let merged: ToolSet = { ...connectorTools, ...mcpTools }; + if (hideDocumentaryRag) merged = omitDocumentaryRagTools(merged); + const tools = filterTools(merged, this.definition.toolAllowlist); const stream = streamText({ model, @@ -104,6 +108,8 @@ export class DefaultAgent implements Agent { messages: modelMessages, tools, stopWhen: stepCountIs(5), + temperature: this.definition.temperature ?? undefined, + abortSignal: ctx.abortSignal, }); return { kind: "stream", stream }; diff --git a/src/lib/orchestrator/agents/drafting.ts b/src/lib/orchestrator/agents/drafting.ts new file mode 100644 index 0000000..158127e --- /dev/null +++ b/src/lib/orchestrator/agents/drafting.ts @@ -0,0 +1,40 @@ +import type { + Agent, + AgentContext, + AgentDefinition, + AgentRunResult, +} from "../types"; +import { runAgentStream } from "./base"; + +export const DRAFTING_SYSTEM_PROMPT = `Tu es l'AGENT RÉDACTEUR d'un cabinet d'IA juridique. Ton rôle : produire le livrable final (acte, mémoire, courrier, note de synthèse) en français juridique soigné, à partir de la recherche et des positions produites par les agents précédents. + +Discipline de rédaction : + +1. Appuie-toi sur la matière fournie (sources, positions des agents précédents). Tu ne réinventes pas le droit et ne cites que ce qui est sourcé. Si une référence manque, signale-le — n'invente jamais. +2. Quand l'utilisateur demande un FICHIER (« rédige une mise en demeure et exporte en docx », « fais-moi un mémo PDF »), appelle directement \`generate_document\` SANS annoncer en prose ce que tu vas faire — appelle l'outil, puis commente brièvement après. +3. Pour retoucher un document existant, utilise \`edit_document\`. +4. Si tu as besoin de vérifier une référence pendant la rédaction, appelle \`legifrance_search\`. + +Style : registre formel, structure adaptée au type d'acte (exposé des faits → moyens → dispositif/demande pour un acte ; problématique → analyse → recommandation pour une note). Pas d'emphase, pas de formules creuses.`; + +/** + * DraftingAgent — le « Rédacteur » du cabinet d'IA. Produit le livrable final + * et a accès aux outils de génération/édition de documents (+ vérification de + * sources). C'est typiquement l'agent terminal d'une pipeline de rédaction. + */ +export class DraftingAgent implements Agent { + constructor(public readonly definition: AgentDefinition) {} + + async run(ctx: AgentContext): Promise { + return runAgentStream(this.definition, ctx, { + systemPrompt: DRAFTING_SYSTEM_PROMPT, + toolAllowlist: [ + "generate_document", + "edit_document", + "search_documents", + "legifrance_search", + ], + maxSteps: 6, + }); + } +} diff --git a/src/lib/orchestrator/agents/index.ts b/src/lib/orchestrator/agents/index.ts index 21ca3fd..64c7f9c 100644 --- a/src/lib/orchestrator/agents/index.ts +++ b/src/lib/orchestrator/agents/index.ts @@ -3,6 +3,8 @@ import { DefaultAgent } from "./default"; import { ResearchAgent } from "./research"; import { CitatorAgent } from "./citator"; import { ReviewerAgent } from "./reviewer"; +import { DraftingAgent } from "./drafting"; +import { LegifranceAgent } from "./legifrance"; import { OrchestratorAgent } from "./orchestrator-agent"; /** @@ -18,6 +20,8 @@ export const AGENT_REGISTRY: Partial< research: ResearchAgent, citator: CitatorAgent, reviewer: ReviewerAgent, + drafting: DraftingAgent, + legifrance: LegifranceAgent, }; export function resolveAgentConstructor( @@ -30,6 +34,8 @@ export { DefaultAgent } from "./default"; export { ResearchAgent, RESEARCH_SYSTEM_PROMPT } from "./research"; export { CitatorAgent, CITATOR_SYSTEM_PROMPT } from "./citator"; export { ReviewerAgent, REVIEWER_SYSTEM_PROMPT } from "./reviewer"; +export { DraftingAgent, DRAFTING_SYSTEM_PROMPT } from "./drafting"; +export { LegifranceAgent, LEGIFRANCE_SYSTEM_PROMPT } from "./legifrance"; export { OrchestratorAgent, ORCHESTRATOR_SYSTEM_PROMPT, diff --git a/src/lib/orchestrator/agents/legifrance.ts b/src/lib/orchestrator/agents/legifrance.ts new file mode 100644 index 0000000..fad3397 --- /dev/null +++ b/src/lib/orchestrator/agents/legifrance.ts @@ -0,0 +1,35 @@ +import type { + Agent, + AgentContext, + AgentDefinition, + AgentRunResult, +} from "../types"; +import { runAgentStream } from "./base"; + +export const LEGIFRANCE_SYSTEM_PROMPT = `Tu es l'AGENT LÉGIFRANCE d'un cabinet d'IA juridique. Ton unique rôle : interroger Légifrance (via l'outil \`legifrance_search\`) pour rapporter les textes officiels (codes, lois, décrets, jurisprudence) pertinents à la question, avec leur référence exacte et leur URL Légifrance. + +Discipline : + +1. Appelle SYSTÉMATIQUEMENT \`legifrance_search\` — ne cite jamais un article ou une décision de ta seule mémoire. +2. Pour chaque résultat utile : intitulé exact, référence (numéro d'article / de pourvoi), URL Légifrance, et une phrase d'apport. +3. Tu ne rédiges pas, tu ne donnes pas d'avis : tu fournis la matière sourcée, brute et fiable, pour les agents qui suivent. +4. Si une recherche ne renvoie rien de pertinent, dis-le explicitement plutôt que de fabriquer une référence. + +Format : une liste de sources, chacune avec sa référence et son URL. Pas d'introduction ni de conclusion.`; + +/** + * LegifranceAgent — agent de sourcing spécialisé sur Légifrance (droit + * français officiel). Allowlist réduite au seul \`legifrance_search\` pour + * rester focalisé sur la matière légale/jurisprudentielle. + */ +export class LegifranceAgent implements Agent { + constructor(public readonly definition: AgentDefinition) {} + + async run(ctx: AgentContext): Promise { + return runAgentStream(this.definition, ctx, { + systemPrompt: LEGIFRANCE_SYSTEM_PROMPT, + toolAllowlist: ["legifrance_search"], + maxSteps: 5, + }); + } +} diff --git a/src/lib/orchestrator/agents/rag-scope.test.ts b/src/lib/orchestrator/agents/rag-scope.test.ts new file mode 100644 index 0000000..e83f6e5 --- /dev/null +++ b/src/lib/orchestrator/agents/rag-scope.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; +import type { ToolSet } from "ai"; +import { + resolveAgentRag, + omitDocumentaryRagTools, + intersectDocIds, + DOCUMENTARY_RAG_TOOLS, +} from "./rag-scope"; +import type { AgentContext } from "../types"; + +function projectCtx(): AgentContext { + return { + userId: "u-1", + conversationId: "c-1", + messages: [], + projectId: "p-1", + projectDocumentIds: ["d-1", "d-2"], + projectFolderId: "f-root", + }; +} + +function globalCtx(): AgentContext { + return { userId: "u-1", conversationId: "c-1", messages: [] }; +} + +describe("resolveAgentRag", () => { + it("inherit (null) en conversation projet = périmètre conversation", async () => { + const { scope, hideDocumentaryRag } = await resolveAgentRag( + projectCtx(), + null + ); + expect(hideDocumentaryRag).toBe(false); + expect(scope).toEqual({ + projectId: "p-1", + conversationId: "c-1", + documentIds: ["d-1", "d-2"], + folderId: "f-root", + }); + }); + + it("inherit explicite = même périmètre", async () => { + const { scope } = await resolveAgentRag(projectCtx(), { mode: "inherit" }); + expect(scope?.documentIds).toEqual(["d-1", "d-2"]); + }); + + it("project = périmètre projet complet (comme inherit en conv. projet)", async () => { + const { scope, hideDocumentaryRag } = await resolveAgentRag(projectCtx(), { + mode: "project", + }); + expect(hideDocumentaryRag).toBe(false); + expect(scope?.documentIds).toEqual(["d-1", "d-2"]); + }); + + it("none en conversation projet vide les documents + masque les outils", async () => { + const { scope, hideDocumentaryRag } = await resolveAgentRag(projectCtx(), { + mode: "none", + }); + expect(hideDocumentaryRag).toBe(true); + expect(scope?.documentIds).toEqual([]); + // le reste du scope projet est conservé (destination des docs générés) + expect(scope?.folderId).toBe("f-root"); + }); + + it("none en conversation globale masque les outils (pas de scope)", async () => { + const { scope, hideDocumentaryRag } = await resolveAgentRag(globalCtx(), { + mode: "none", + }); + expect(hideDocumentaryRag).toBe(true); + expect(scope).toBeUndefined(); + }); + + it("conversation globale + inherit = aucun scope (RAG global inchangé)", async () => { + const { scope, hideDocumentaryRag } = await resolveAgentRag( + globalCtx(), + null + ); + expect(hideDocumentaryRag).toBe(false); + expect(scope).toBeUndefined(); + }); + + it("documents : intersection avec le projet — un doc hors projet est exclu", async () => { + const { scope } = await resolveAgentRag(projectCtx(), { + mode: "documents", + documentIds: ["d-1", "d-hors-projet"], + }); + // projet = [d-1, d-2] ; d-hors-projet n'y est pas → exclu (jamais union) + expect(scope?.documentIds).toEqual(["d-1"]); + }); + + it("documents hors conversation projet = repli global (pas d'intersection possible)", async () => { + const { scope, hideDocumentaryRag } = await resolveAgentRag(globalCtx(), { + mode: "documents", + documentIds: ["d-1"], + }); + expect(scope).toBeUndefined(); + expect(hideDocumentaryRag).toBe(false); + }); +}); + +describe("intersectDocIds", () => { + it("ne garde que les éléments présents dans allowed", () => { + expect(intersectDocIds(["a", "b", "c"], ["b", "c", "d"])).toEqual([ + "b", + "c", + ]); + }); + it("intersection vide si aucun élément commun", () => { + expect(intersectDocIds(["x"], ["y", "z"])).toEqual([]); + }); + it("allowed vide = aucun document", () => { + expect(intersectDocIds(["a", "b"], [])).toEqual([]); + }); +}); + +describe("omitDocumentaryRagTools", () => { + it("retire exactement les outils de lecture documentaire", () => { + const tools = { + search_documents: {}, + list_documents: {}, + read_document: {}, + find_in_document: {}, + generate_document: {}, + edit_document: {}, + legifrance_search: {}, + search_conversation_history: {}, + } as unknown as ToolSet; + + const out = omitDocumentaryRagTools(tools); + for (const t of DOCUMENTARY_RAG_TOOLS) { + expect(out).not.toHaveProperty(t); + } + // création + connecteurs + historique conservés + expect(out).toHaveProperty("generate_document"); + expect(out).toHaveProperty("edit_document"); + expect(out).toHaveProperty("legifrance_search"); + expect(out).toHaveProperty("search_conversation_history"); + }); +}); diff --git a/src/lib/orchestrator/agents/rag-scope.ts b/src/lib/orchestrator/agents/rag-scope.ts new file mode 100644 index 0000000..0945c17 --- /dev/null +++ b/src/lib/orchestrator/agents/rag-scope.ts @@ -0,0 +1,102 @@ +import type { ToolSet } from "ai"; +import type { ToolScope } from "@/lib/connectors/tools"; +import { getDocsInFolders } from "@/lib/projects/scope"; +import type { AgentContext, AgentRagScope } from "../types"; + +/** Intersection : ne garde de `chosen` que ce qui est déjà dans `allowed`. */ +export function intersectDocIds(chosen: string[], allowed: string[]): string[] { + const allowedSet = new Set(allowed); + return chosen.filter((id) => allowedSet.has(id)); +} + +/** + * Outils qui LISENT les documents RAG de l'utilisateur (vs outils de création + * `generate_document`/`edit_document` ou connecteurs externes). Le mode + * `none` d'un agent masque exactement ceux-là. + */ +export const DOCUMENTARY_RAG_TOOLS = [ + "search_documents", + "list_documents", + "read_document", + "find_in_document", +] as const; + +/** Retire les outils de lecture documentaire d'un toolset (mode `none`). */ +export function omitDocumentaryRagTools(tools: ToolSet): ToolSet { + const drop = new Set(DOCUMENTARY_RAG_TOOLS); + return Object.fromEntries( + Object.entries(tools).filter(([name]) => !drop.has(name)) + ) as ToolSet; +} + +/** Périmètre documentaire de la conversation (base de toute restriction). */ +function conversationScope(ctx: AgentContext): ToolScope | undefined { + return ctx.projectId + ? { + projectId: ctx.projectId, + conversationId: ctx.conversationId, + documentIds: ctx.projectDocumentIds ?? [], + folderId: ctx.projectFolderId ?? null, + } + : undefined; +} + +/** + * Résout la portée documentaire RAG d'UN agent à partir de sa `ragScope` et du + * périmètre de la conversation. Renvoie le `ToolScope` à passer à + * `buildToolsForUser` (donc on ne touche ni cette fonction ni `search.ts`) et + * un drapeau `hideDocumentaryRag` pour le cas `none` en conversation globale. + * + * Invariant de sécurité : la portée d'un agent est TOUJOURS une intersection + * avec le périmètre de la conversation — jamais une extension. Le dossier de + * destination des documents générés reste celui du projet. + * + * Lot 1a : `inherit`/`project`/`none` (purs, 0 requête). `folders`/`documents` + * sont câblés en Lot 1b (intersection via requêtes). + */ +export async function resolveAgentRag( + ctx: AgentContext, + ragScope: AgentRagScope | null | undefined +): Promise<{ scope: ToolScope | undefined; hideDocumentaryRag: boolean }> { + const base = conversationScope(ctx); + + if (!ragScope || ragScope.mode === "inherit" || ragScope.mode === "project") { + return { scope: base, hideDocumentaryRag: false }; + } + + if (ragScope.mode === "none") { + // En mode projet, documentIds=[] suffit (search_documents non proposé) ; + // on masque aussi les outils documentaires pour couvrir la conversation + // globale (sans projet, donc sans scope à vider). + return { + scope: base ? { ...base, documentIds: [] } : undefined, + hideDocumentaryRag: true, + }; + } + + // folders / documents : restriction PAR INTERSECTION avec le périmètre + // projet. Hors conversation projet (base absent), on ne peut pas intersecter + // un périmètre curé → repli sûr sur le comportement global (jamais au-delà + // des documents de l'utilisateur, garanti par le filtre userId de search.ts). + if (!base) return { scope: undefined, hideDocumentaryRag: false }; + + if (ragScope.mode === "documents") { + return { + scope: { + ...base, + documentIds: intersectDocIds(ragScope.documentIds, base.documentIds), + }, + hideDocumentaryRag: false, + }; + } + + // folders : on résout les documents des sous-arbres choisis, puis intersection. + const inFolders = await getDocsInFolders(ctx.userId, ragScope.folderIds); + return { + scope: { + ...base, + documentIds: intersectDocIds(inFolders, base.documentIds), + }, + hideDocumentaryRag: false, + }; +} diff --git a/src/lib/orchestrator/cost-estimate.test.ts b/src/lib/orchestrator/cost-estimate.test.ts new file mode 100644 index 0000000..9dd50d1 --- /dev/null +++ b/src/lib/orchestrator/cost-estimate.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + estimateCalls, + estimateRunCost, + estimateTokensFromChars, +} from "./cost-estimate"; + +describe("estimateCalls", () => { + it("council 3 agents / 2 tours = 5 appels", () => { + expect(estimateCalls({ mode: "council", agents: 3, rounds: 2 })).toBe(5); + }); + + it("sequential 3 agents = 3 appels", () => { + expect(estimateCalls({ mode: "sequential", agents: 3 })).toBe(3); + }); + + it("parallel 3 agents = 3 appels (2 workers + 1 synthèse)", () => { + expect(estimateCalls({ mode: "parallel", agents: 3 })).toBe(3); + }); + + it("council 1 tour par défaut", () => { + expect(estimateCalls({ mode: "council", agents: 4 })).toBe(4); // 1*(4-1)+1 + }); + + it("mono-agent (ou vide) = 1 appel quel que soit le mode", () => { + expect(estimateCalls({ mode: "council", agents: 1, rounds: 3 })).toBe(1); + expect(estimateCalls({ mode: "sequential", agents: 0 })).toBe(1); + expect(estimateCalls({ mode: "parallel", agents: 1 })).toBe(1); + }); + + it("council 5 agents / 4 tours = 17 appels", () => { + expect(estimateCalls({ mode: "council", agents: 5, rounds: 4 })).toBe(17); + }); +}); + +describe("estimateTokensFromChars", () => { + it("≈ 4 caractères par token", () => { + expect(estimateTokensFromChars(0)).toBe(0); + expect(estimateTokensFromChars(4)).toBe(1); + expect(estimateTokensFromChars(10)).toBe(3); // ceil(2.5) + expect(estimateTokensFromChars(-5)).toBe(0); + }); +}); + +describe("estimateRunCost", () => { + it("null pour un modèle sans prix connu (auto-hébergé / hors table)", () => { + expect( + estimateRunCost({ modelId: "ollama-local", calls: 3, promptChars: 400 }) + ).toBeNull(); + expect( + estimateRunCost({ modelId: null, calls: 1, promptChars: 100 }) + ).toBeNull(); + }); + + it("coût > 0 et croissant avec le nombre d'appels pour un modèle tarifé", () => { + const one = estimateRunCost({ + modelId: "claude-opus-4-7", + calls: 1, + promptChars: 400, + }); + const five = estimateRunCost({ + modelId: "claude-opus-4-7", + calls: 5, + promptChars: 400, + }); + expect(one?.amount).toBeGreaterThan(0); + expect(five?.amount).toBeGreaterThan(one!.amount); + expect(one?.currency).toBe("USD"); + }); +}); diff --git a/src/lib/orchestrator/cost-estimate.ts b/src/lib/orchestrator/cost-estimate.ts new file mode 100644 index 0000000..a6a9dc3 --- /dev/null +++ b/src/lib/orchestrator/cost-estimate.ts @@ -0,0 +1,59 @@ +import { computeCost, type Cost } from "@/lib/providers/pricing"; +import type { PipelineMode } from "./types"; + +/** + * Nombre d'appels LLM qu'un run de pipeline déclenchera, selon le mode. + * C'est le vrai driver de coût d'un run multi-agents — exposé AU POINT DE + * DÉPENSE (composer du chat, CTA « Essayer » du board) et non plus seulement + * dans l'éditeur. Source de vérité unique, réutilisée par pipeline-mode-bar. + * + * - sequential : un appel par agent (A → B → C). + * - council : débatteurs (agents − 1) × tours + 1 synthèse. + * - parallel : workers (agents − 1) en parallèle + 1 synthèse = agents. + * + * Un pipeline mono-agent (ou vide) = 1 appel. + */ +export function estimateCalls(opts: { + mode: PipelineMode; + agents: number; + rounds?: number; +}): number { + const agents = Math.max(1, Math.floor(opts.agents)); + if (agents <= 1) return 1; + const rounds = Math.max(1, Math.floor(opts.rounds ?? 1)); + switch (opts.mode) { + case "council": + return rounds * (agents - 1) + 1; + case "parallel": + return agents - 1 + 1; + case "sequential": + default: + return agents; + } +} + +/** Heuristique grossière de tokenisation : ~4 caractères par token. */ +export function estimateTokensFromChars(chars: number): number { + return Math.ceil(Math.max(0, chars) / 4); +} + +/** Tokens de sortie supposés par appel pour l'estimation (réponse type). */ +const ASSUMED_OUTPUT_TOKENS_PER_CALL = 700; + +/** + * Estimation de coût AVANT génération. Volontairement une fourchette : les + * tokens de sortie sont inconnus à l'avance, donc l'appelant suffixe + * « estimé ». Retourne `null` si le modèle n'a pas de prix connu + * (auto-hébergé / hors table) → l'appelant affiche « auto-hébergé » plutôt + * qu'un « 0 € » trompeur. + */ +export function estimateRunCost(opts: { + modelId: string | null | undefined; + calls: number; + promptChars: number; +}): Cost | null { + const calls = Math.max(1, opts.calls); + const inputTokens = estimateTokensFromChars(opts.promptChars) * calls; + const outputTokens = ASSUMED_OUTPUT_TOKENS_PER_CALL * calls; + return computeCost(opts.modelId, inputTokens, outputTokens); +} diff --git a/src/lib/orchestrator/orchestrator.test.ts b/src/lib/orchestrator/orchestrator.test.ts index e453513..83c1a73 100644 --- a/src/lib/orchestrator/orchestrator.test.ts +++ b/src/lib/orchestrator/orchestrator.test.ts @@ -369,6 +369,59 @@ describe("Orchestrator: mode council", () => { expect(startEvents[startEvents.length - 1].agentId).toBe("synth"); }); + it("H10 : si le synthétiseur échoue, sert les positions brutes (sans throw)", async () => { + const pipeline: PipelineConfig = { + slug: "council-fallback", + name: "Council", + mode: "council", + rounds: 1, + agents: [ + makeAgentDef("d1", "default-chat", "Débateur 1"), + makeAgentDef("d2", "default-chat", "Débateur 2"), + makeAgentDef("synth", "orchestrator", "Synthétiseur"), + ], + }; + + const orchestrator = new Orchestrator(pipeline); + const { writer, parts } = makeWriter(); + const events: OrchestratorEvent[] = []; + + // Ne doit PAS lever : l'échec de synthèse est rattrapé par le fallback. + await expect( + orchestrator.run({ + ctx: makeCtx(), + writer, + onEvent: (e) => { + events.push(e); + }, + agentFactory: (def) => + new FakeAgent(def, `position-${def.id}`, undefined, def.id === "synth"), + }) + ).resolves.toBeUndefined(); + + // Le synthétiseur a bien émis une erreur (honnêteté du panel + audit). + expect( + events.some((e) => e.type === "agent_error" && e.agentId === "synth") + ).toBe(true); + + // Du vrai texte a été streamé (text-start/text-delta/text-end) — la seule + // voie rendue et persistée. + const typed = parts.filter( + (p): p is { type: string; delta?: string } => + !!p && typeof p === "object" && "type" in p + ); + expect(typed.some((p) => p.type === "text-start")).toBe(true); + expect(typed.some((p) => p.type === "text-end")).toBe(true); + + const delta = typed.find((p) => p.type === "text-delta")?.delta ?? ""; + expect(delta).toContain("Synthèse échouée"); + // Les positions brutes des deux débatteurs sont présentes. + expect(delta).toContain("position-d1"); + expect(delta).toContain("position-d2"); + // Le texte de repli n'est pas vide. + expect(delta.length).toBeGreaterThan(40); + }); + it("au tour 2, les débatteurs voient les positions du tour 1", async () => { const observed = new Map(); const pipeline: PipelineConfig = { diff --git a/src/lib/orchestrator/orchestrator.ts b/src/lib/orchestrator/orchestrator.ts index 3277207..c6b9c9e 100644 --- a/src/lib/orchestrator/orchestrator.ts +++ b/src/lib/orchestrator/orchestrator.ts @@ -246,6 +246,7 @@ export class Orchestrator { outputTokens: text.outputTokens, preview: preview(text.value), round, + modelId: def.modelOverride ?? null, }); return { agentId: def.id, @@ -319,7 +320,10 @@ export class Orchestrator { }); } catch (err) { await this.emitError(args, writer, synthesizer, pipelineRunId, err); - throw err; + // H10 : fallback — on sert les positions brutes plutôt qu'une erreur + // vide. Pas de re-throw : le run se termine proprement et le texte de + // repli est persisté comme une réponse normale. + this.streamStaticText(writer, this.buildSynthesisFallback(priorOutputs)); } } @@ -415,6 +419,7 @@ export class Orchestrator { inputTokens: text.inputTokens, outputTokens: text.outputTokens, preview: preview(text.value), + modelId: def.modelOverride ?? null, }); return { agentId: def.id, @@ -467,7 +472,9 @@ export class Orchestrator { }); } catch (err) { await this.emitError(args, writer, synthesizer, pipelineRunId, err); - throw err; + // H10 : même fallback qu'en council — positions brutes des workers + // plutôt qu'une erreur vide. + this.streamStaticText(writer, this.buildSynthesisFallback(priorOutputs)); } } @@ -512,6 +519,7 @@ export class Orchestrator { inputTokens: text.inputTokens, outputTokens: text.outputTokens, preview: preview(text.value), + modelId: def.modelOverride ?? null, }); } @@ -539,6 +547,7 @@ export class Orchestrator { inputTokens: usage?.inputTokens ?? undefined, outputTokens: usage?.outputTokens ?? undefined, preview: preview(finalText), + modelId: def.modelOverride ?? null, }); } else { args.writer.write({ @@ -555,10 +564,39 @@ export class Orchestrator { inputTokens: result.inputTokens, outputTokens: result.outputTokens, preview: preview(result.text), + modelId: def.modelOverride ?? null, }); } } + /** + * H10 — quand le synthétiseur échoue, on ne renvoie pas une réponse vide à + * l'utilisateur : on sert les positions brutes du conseil, précédées d'un + * avertissement clair (non arbitrées, non vérifiées). On émet du vrai texte + * (text-start/delta/end) — la seule voie effectivement rendue ET persistée + * par `route.ts` (data-final-text n'est consommé nulle part). + */ + private streamStaticText(writer: OrchestratorWriter, text: string): void { + const id = nanoid(); + writer.write({ type: "text-start", id }); + writer.write({ type: "text-delta", id, delta: text }); + writer.write({ type: "text-end", id }); + } + + private buildSynthesisFallback(priorOutputs: AgentPriorOutput[]): string { + const header = + "> ⚠️ **Synthèse échouée** — le synthétiseur n'a pas pu produire de décision finale.\n>\n" + + "> Voici les **positions brutes** exprimées par le conseil, **ni arbitrées ni vérifiées**. À relire et valider par un juriste avant tout usage."; + if (priorOutputs.length === 0) { + return `${header}\n\n_Aucune position n'a pu être recueillie._`; + } + const blocks = priorOutputs.map((p) => { + const tour = typeof p.round === "number" ? ` · tour ${p.round}` : ""; + return `### ${p.label}${tour}\n\n${p.output.trim()}`; + }); + return `${header}\n\n${blocks.join("\n\n---\n\n")}`; + } + private async emitError( args: OrchestratorRunArgs, writer: OrchestratorWriter, @@ -576,6 +614,7 @@ export class Orchestrator { label: def.label, error: message, round, + modelId: def.modelOverride ?? null, }); } diff --git a/src/lib/orchestrator/repository.ts b/src/lib/orchestrator/repository.ts index d35b3ff..98abc06 100644 --- a/src/lib/orchestrator/repository.ts +++ b/src/lib/orchestrator/repository.ts @@ -60,6 +60,8 @@ export async function loadPipelineForUser( modelOverride: a.modelOverride ?? fallback.modelOverride ?? null, systemPrompt: a.systemPrompt ?? null, toolAllowlist: a.toolAllowlist ?? null, + ragScope: a.ragScope ?? null, + temperature: a.temperature ?? null, })); return { diff --git a/src/lib/orchestrator/retry.test.ts b/src/lib/orchestrator/retry.test.ts index b5e7c78..69aaa5b 100644 --- a/src/lib/orchestrator/retry.test.ts +++ b/src/lib/orchestrator/retry.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { isRetryableError, withRetry } from "./retry"; +import { isAbortError, isRetryableError, withRetry } from "./retry"; describe("isRetryableError", () => { it("retry sur HTTP 429", () => { @@ -79,6 +79,38 @@ describe("isRetryableError", () => { isRetryableError({ message: "Invalid request payload" }) ).toBe(false); }); + + // R2 : un « Stop » utilisateur (abort) ne doit JAMAIS être retryé, sinon + // l'annulation relancerait paradoxalement la dépense LLM. + it("PAS de retry sur AbortError (name)", () => { + expect( + isRetryableError({ name: "AbortError", message: "The operation was aborted" }) + ).toBe(false); + }); + + it("PAS de retry sur DOMException AbortError", () => { + expect(isRetryableError(new DOMException("aborted", "AbortError"))).toBe( + false + ); + }); + + it("PAS de retry sur un abort même si le message ressemble à 'fetch failed'", () => { + // Un abort peut, selon le provider, se présenter avec un message réseau + // qui matcherait sinon un pattern retryable — le garde abort doit primer. + expect( + isRetryableError({ name: "AbortError", message: "fetch failed" }) + ).toBe(false); + }); +}); + +describe("isAbortError", () => { + it("détecte name AbortError et DOMException, ignore le reste", () => { + expect(isAbortError({ name: "AbortError" })).toBe(true); + expect(isAbortError(new DOMException("x", "AbortError"))).toBe(true); + expect(isAbortError({ name: "TimeoutError" })).toBe(true); + expect(isAbortError({ statusCode: 429 })).toBe(false); + expect(isAbortError(null)).toBe(false); + }); }); describe("withRetry", () => { @@ -172,4 +204,12 @@ describe("withRetry", () => { ).rejects.toThrow(); expect(fn).toHaveBeenCalledTimes(2); }); + + it("ne relance PAS une AbortError (Stop = pas de reprise de dépense)", async () => { + const fn = vi.fn(async () => { + throw new DOMException("aborted", "AbortError"); + }); + await expect(withRetry(fn, { backoffMs: [5, 5] })).rejects.toThrow(); + expect(fn).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/lib/orchestrator/retry.ts b/src/lib/orchestrator/retry.ts index 5017a4d..06303fa 100644 --- a/src/lib/orchestrator/retry.ts +++ b/src/lib/orchestrator/retry.ts @@ -14,8 +14,22 @@ * 404 (model not found), 422 (validation) * - Patterns : "invalid_api_key", "model_not_found" */ +/** + * Annulation volontaire (« Stop » utilisateur → req.signal aborté, ou + * AbortSignal.timeout). Jamais retryable : relancer reviendrait à reprendre + * la dépense LLM que l'utilisateur vient d'annuler. À tester en priorité car + * un abort peut, selon le provider, ressembler à un « fetch failed » réseau + * (sinon faussement classé retryable par les patterns plus bas). + */ +export function isAbortError(err: unknown): boolean { + if (err instanceof DOMException && err.name === "AbortError") return true; + const name = (err as { name?: string } | null)?.name; + return name === "AbortError" || name === "TimeoutError"; +} + export function isRetryableError(err: unknown): boolean { if (!err) return false; + if (isAbortError(err)) return false; const e = err as { statusCode?: number; data?: { code?: string }; message?: string }; // 1. HTTP status code (présent sur les AI_APICallError) diff --git a/src/lib/orchestrator/types.ts b/src/lib/orchestrator/types.ts index ec104cf..ef1625b 100644 --- a/src/lib/orchestrator/types.ts +++ b/src/lib/orchestrator/types.ts @@ -1,4 +1,7 @@ import type { streamText, UIMessage } from "ai"; +import type { AgentRagScope } from "@/db/schema/pipelines"; + +export type { AgentRagScope }; /** * Type retourné par streamText() — utiliser le type inféré garde la @@ -38,6 +41,16 @@ export interface AgentDefinition { * outils disponibles à l'utilisateur (connecteurs + MCP). */ toolAllowlist?: string[] | null; + /** + * Portée documentaire RAG propre à l'agent. null/undefined/`inherit` = + * périmètre de la conversation (comportement historique). Cf. resolveAgentRag. + */ + ragScope?: AgentRagScope | null; + /** + * Température d'échantillonnage. null/undefined = défaut du provider. + * Bas (~0.2) = factuel/déterministe ; haut (~0.8) = créatif. + */ + temperature?: number | null; } /** @@ -72,10 +85,27 @@ export interface AgentContext { messages: UIMessage[]; documentIds?: string[]; systemPromptExtras?: string; + /** + * Périmètre projet de la conversation (modèle dossier = projet). Quand il + * est présent, les outils documentaires sont scopés aux documents du projet + * et l'outil de recherche dans l'historique des conversations est activé. + */ + projectId?: string | null; + /** IDs des documents du projet (sous-arbre du dossier-racine). */ + projectDocumentIds?: string[]; + /** Dossier-racine du projet — destination des documents générés/édités. */ + projectFolderId?: string | null; /** Sortie texte des agents précédents dans la pipeline, dans l'ordre. */ priorOutputs?: AgentPriorOutput[]; /** Tag de corrélation pour le tracing. */ pipelineRunId?: string; + /** + * Signal d'annulation propagé depuis la requête HTTP (req.signal). Quand + * l'utilisateur clique « Stop », le fetch est aborté → ce signal s'abort → + * propagé jusqu'à streamText pour couper réellement l'appel LLM serveur + * (et donc la facturation), pas seulement le rendu client. + */ + abortSignal?: AbortSignal; } export interface AgentPriorOutput { @@ -135,6 +165,12 @@ export type OrchestratorEvent = outputTokens?: number; preview?: string; round?: number; + /** + * Modèle effectif de CET agent (def.modelOverride). Sert au coût par + * agent (agent_runs.modelId) : sans lui, l'audit trail recopierait le + * modèle global au lieu du modèle réellement utilisé par l'agent. + */ + modelId?: string | null; } | { type: "agent_error"; @@ -144,6 +180,7 @@ export type OrchestratorEvent = label: string; error: string; round?: number; + modelId?: string | null; }; export type OrchestratorEventListener = (event: OrchestratorEvent) => void; diff --git a/src/lib/projects/scope.ts b/src/lib/projects/scope.ts new file mode 100644 index 0000000..d8a4220 --- /dev/null +++ b/src/lib/projects/scope.ts @@ -0,0 +1,254 @@ +import { and, asc, eq, inArray } from "drizzle-orm"; +import { db } from "@/db"; +import { + projects, + documentFolders, + documents, + documentChunks, +} from "@/db/schema"; + +/** + * Modèle « dossier = projet » : un projet est rattaché à un dossier-racine + * (`projects.folderId`) et ses documents sont tous ceux rangés dans ce + * dossier ou un de ses sous-dossiers, récursivement. Ce helper résout ce + * périmètre pour le scoping du RAG, l'affichage de la page projet et les + * compteurs de la liste. `documents.projectId` n'est plus la source de + * vérité de l'appartenance documentaire. + */ + +export type ProjectScope = { + folderId: string | null; + folderIds: string[]; + documentIds: string[]; +}; + +/** IDs des dossiers du sous-arbre enraciné en `rootFolderId` (inclus). */ +function collectSubtree( + rootFolderId: string, + childrenByParent: Map +): string[] { + const out: string[] = []; + const stack: string[] = [rootFolderId]; + while (stack.length > 0) { + const id = stack.pop() as string; + out.push(id); + const children = childrenByParent.get(id); + if (children) stack.push(...children); + } + return out; +} + +function buildChildrenMap( + folders: { id: string; parentFolderId: string | null }[] +): Map { + const childrenByParent = new Map(); + for (const f of folders) { + const list = childrenByParent.get(f.parentFolderId) ?? []; + list.push(f.id); + childrenByParent.set(f.parentFolderId, list); + } + return childrenByParent; +} + +/** + * Résout le périmètre documentaire d'un seul projet : son dossier-racine, + * tous les dossiers de son sous-arbre, et les IDs des documents qu'ils + * contiennent. Renvoie des listes vides si le projet n'a pas (ou plus) de + * dossier rattaché. + */ +export async function getProjectScope( + userId: string, + projectId: string +): Promise { + const [project] = await db + .select({ folderId: projects.folderId }) + .from(projects) + .where(and(eq(projects.id, projectId), eq(projects.userId, userId))) + .limit(1); + + if (!project?.folderId) { + return { folderId: project?.folderId ?? null, folderIds: [], documentIds: [] }; + } + + const folders = await db + .select({ + id: documentFolders.id, + parentFolderId: documentFolders.parentFolderId, + }) + .from(documentFolders) + .where(eq(documentFolders.userId, userId)); + + const folderIds = collectSubtree( + project.folderId, + buildChildrenMap(folders) + ); + + const docs = await db + .select({ id: documents.id }) + .from(documents) + .where( + and(eq(documents.userId, userId), inArray(documents.folderId, folderIds)) + ); + + return { + folderId: project.folderId, + folderIds, + documentIds: docs.map((d) => d.id), + }; +} + +/** + * IDs des documents rangés dans les sous-arbres des dossiers donnés + * (récursif). Sert à la portée RAG « dossiers choisis » d'un agent (Board). + * Filtré par `userId` : un dossier d'un autre tenant ne ramène jamais de + * document (garde-fou en plus de l'intersection projet côté appelant). + */ +export async function getDocsInFolders( + userId: string, + folderIds: string[] +): Promise { + if (folderIds.length === 0) return []; + + const folders = await db + .select({ + id: documentFolders.id, + parentFolderId: documentFolders.parentFolderId, + }) + .from(documentFolders) + .where(eq(documentFolders.userId, userId)); + + const childrenByParent = buildChildrenMap(folders); + const allowed = new Set(); + for (const fid of folderIds) { + for (const id of collectSubtree(fid, childrenByParent)) allowed.add(id); + } + + const docs = await db + .select({ id: documents.id }) + .from(documents) + .where( + and( + eq(documents.userId, userId), + inArray(documents.folderId, Array.from(allowed)) + ) + ); + return docs.map((d) => d.id); +} + +export type AgentSourceFolder = { id: string; name: string; depth: number }; +export type AgentSourceDocument = { + id: string; + filename: string; + folderId: string | null; + indexed: boolean; +}; + +/** + * Options pour les sélecteurs « Sources documentaires » d'un agent (Board) : + * l'arborescence des dossiers de l'utilisateur (en ordre DFS avec profondeur + * pour l'indentation) et ses documents (avec un flag `indexed` = au moins un + * chunk RAG, comme la transparence de la page Documents). + */ +export async function getAgentSourceOptions(userId: string): Promise<{ + folders: AgentSourceFolder[]; + documents: AgentSourceDocument[]; +}> { + const [folderRows, docRows, chunkRows] = await Promise.all([ + db + .select({ + id: documentFolders.id, + name: documentFolders.name, + parentFolderId: documentFolders.parentFolderId, + }) + .from(documentFolders) + .where(eq(documentFolders.userId, userId)), + db + .select({ + id: documents.id, + filename: documents.filename, + folderId: documents.folderId, + }) + .from(documents) + .where(eq(documents.userId, userId)) + .orderBy(asc(documents.filename)), + db + .selectDistinct({ documentId: documentChunks.documentId }) + .from(documentChunks) + .innerJoin(documents, eq(documents.id, documentChunks.documentId)) + .where(eq(documents.userId, userId)), + ]); + + const childrenByParent = buildChildrenMap(folderRows); + const nameById = new Map(folderRows.map((f) => [f.id, f.name])); + const byName = (a: string, b: string) => + (nameById.get(a) ?? "").localeCompare(nameById.get(b) ?? ""); + + const folders: AgentSourceFolder[] = []; + const visit = (id: string, depth: number) => { + folders.push({ id, name: nameById.get(id) ?? id, depth }); + for (const child of (childrenByParent.get(id) ?? []).slice().sort(byName)) { + visit(child, depth + 1); + } + }; + for (const root of (childrenByParent.get(null) ?? []).slice().sort(byName)) { + visit(root, 0); + } + + const indexed = new Set(chunkRows.map((r) => r.documentId)); + const documentsOut: AgentSourceDocument[] = docRows.map((d) => ({ + id: d.id, + filename: d.filename, + folderId: d.folderId, + indexed: indexed.has(d.id), + })); + + return { folders, documents: documentsOut }; +} + +/** + * Compte les documents de chaque projet de l'utilisateur en une passe. + * Évite N appels à `getProjectScope` sur la page liste des projets. + */ +export async function getProjectDocCounts( + userId: string +): Promise> { + const [projectRows, folders, docs] = await Promise.all([ + db + .select({ id: projects.id, folderId: projects.folderId }) + .from(projects) + .where(eq(projects.userId, userId)), + db + .select({ + id: documentFolders.id, + parentFolderId: documentFolders.parentFolderId, + }) + .from(documentFolders) + .where(eq(documentFolders.userId, userId)), + db + .select({ id: documents.id, folderId: documents.folderId }) + .from(documents) + .where(eq(documents.userId, userId)), + ]); + + const childrenByParent = buildChildrenMap(folders); + + const docsByFolder = new Map(); + for (const d of docs) { + if (!d.folderId) continue; + docsByFolder.set(d.folderId, (docsByFolder.get(d.folderId) ?? 0) + 1); + } + + const counts = new Map(); + for (const p of projectRows) { + if (!p.folderId) { + counts.set(p.id, 0); + continue; + } + let n = 0; + for (const fid of collectSubtree(p.folderId, childrenByParent)) { + n += docsByFolder.get(fid) ?? 0; + } + counts.set(p.id, n); + } + return counts; +} diff --git a/src/lib/providers/live-catalog.ts b/src/lib/providers/live-catalog.ts index 869bdc2..0ab5e64 100644 --- a/src/lib/providers/live-catalog.ts +++ b/src/lib/providers/live-catalog.ts @@ -1,5 +1,6 @@ import { decrypt } from "@/lib/crypto"; import type { ProviderKey } from "@/db/schema"; +import { MODEL_CATALOG } from "./models"; /** * Représentation unifiée d'un modèle remonté depuis l'API d'un provider. @@ -87,13 +88,15 @@ export async function fetchLiveModels( return fetchOpenAiCompat(`${trimSlash(key.baseUrl)}/models`, apiKey); } case "ovh": - // OVH expose un endpoint par modèle, pas de catalogue global. On - // remonte une erreur explicite — le UI redirigera vers la liste - // curée locale. - throw new LiveCatalogError( - "OVHcloud expose un endpoint par modèle, pas de catalogue global. Utilisez la liste curée.", - 501 - ); + // OVH expose un endpoint par modèle (pas de catalogue global) → on + // retourne la liste CURÉE locale au lieu d'une erreur 501 (H26 : avant, + // la bibliothèque était un cul-de-sac pour OVH). Le hint signale qu'elle + // est curée. + return MODEL_CATALOG.ovh.map((m) => ({ + id: m.id, + label: m.label, + hint: m.hint ?? "Liste curée OVHcloud", + })); default: { const exhaustive: never = key.type; throw new LiveCatalogError( diff --git a/src/lib/rag/index-document.ts b/src/lib/rag/index-document.ts new file mode 100644 index 0000000..f71bda9 --- /dev/null +++ b/src/lib/rag/index-document.ts @@ -0,0 +1,61 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "@/db"; +import { documents, documentChunks } from "@/db/schema"; +import { chunkText } from "./chunk"; +import { embedTexts, NoEmbeddingProviderError } from "./embed"; + +export type ReindexResult = + | { ok: true; chunks: number } + | { + ok: false; + reason: "not_found" | "no_text" | "no_mistral_key" | "error"; + message?: string; + }; + +/** + * (Ré)indexe un document : supprime ses chunks existants puis recalcule + * chunks + embeddings et les réinsère. Best-effort — l'absence de clé Mistral + * active est signalée explicitement (`no_mistral_key`) pour alimenter l'UI de + * transparence RAG. Vérifie la propriété (userId) avant toute opération. + */ +export async function reindexDocument( + userId: string, + documentId: string +): Promise { + const [doc] = await db + .select({ id: documents.id, extractedText: documents.extractedText }) + .from(documents) + .where(and(eq(documents.id, documentId), eq(documents.userId, userId))) + .limit(1); + if (!doc) return { ok: false, reason: "not_found" }; + if (!doc.extractedText) return { ok: false, reason: "no_text" }; + + const chunks = chunkText(doc.extractedText); + if (chunks.length === 0) return { ok: false, reason: "no_text" }; + + let embeddings: number[][]; + try { + embeddings = await embedTexts(userId, chunks); + } catch (err) { + if (err instanceof NoEmbeddingProviderError) { + return { ok: false, reason: "no_mistral_key" }; + } + return { + ok: false, + reason: "error", + message: err instanceof Error ? err.message : "embedding_failed", + }; + } + + // Remplace l'index existant (idempotent) : on supprime puis réinsère. + await db.delete(documentChunks).where(eq(documentChunks.documentId, documentId)); + await db.insert(documentChunks).values( + chunks.map((content, i) => ({ + documentId, + chunkIndex: i, + content, + embedding: embeddings[i], + })) + ); + return { ok: true, chunks: chunks.length }; +} diff --git a/src/lib/rag/message-search.ts b/src/lib/rag/message-search.ts new file mode 100644 index 0000000..43ae3d5 --- /dev/null +++ b/src/lib/rag/message-search.ts @@ -0,0 +1,90 @@ +import { and, cosineDistance, desc, eq, ne, sql } from "drizzle-orm"; +import { db } from "@/db"; +import { messageChunks, messages, conversations } from "@/db/schema"; +import { chunkText } from "./chunk"; +import { embedQuery, embedTexts, NoEmbeddingProviderError } from "./embed"; + +export type MessageHit = { + conversationId: string; + conversationTitle: string; + role: string; + content: string; + createdAt: Date; + similarity: number; +}; + +/** + * Recherche vectorielle dans l'historique des conversations d'un projet. + * Jointure message_chunks → messages → conversations pour ne garder que les + * conversations de l'utilisateur rattachées au projet. La conversation + * courante peut être exclue (son contenu est déjà dans le contexte du modèle). + */ +export async function searchProjectMessages( + userId: string, + projectId: string, + query: string, + options?: { excludeConversationId?: string | null; limit?: number } +): Promise { + const limit = options?.limit ?? 6; + const queryEmbedding = await embedQuery(userId, query); + + const similarity = sql`1 - (${cosineDistance( + messageChunks.embedding, + queryEmbedding + )})`; + + const conds = [ + eq(conversations.userId, userId), + eq(conversations.projectId, projectId), + ]; + if (options?.excludeConversationId) { + conds.push(ne(conversations.id, options.excludeConversationId)); + } + + const rows = await db + .select({ + conversationId: conversations.id, + conversationTitle: conversations.title, + role: messages.role, + content: messageChunks.content, + createdAt: messages.createdAt, + similarity, + }) + .from(messageChunks) + .innerJoin(messages, eq(messages.id, messageChunks.messageId)) + .innerJoin(conversations, eq(conversations.id, messages.conversationId)) + .where(and(...conds)) + .orderBy(desc(similarity)) + .limit(limit); + + return rows; +} + +/** + * Indexe le contenu d'un message dans message_chunks pour le RAG conversations. + * Best-effort : sans clé Mistral active, on saute silencieusement (le RAG + * documents a la même contrainte). Ne lève jamais — l'indexation ne doit pas + * faire échouer l'enregistrement d'un message. + */ +export async function indexMessageForProject( + userId: string, + messageId: string, + content: string +): Promise { + const chunks = chunkText(content); + if (chunks.length === 0) return; + try { + const embeddings = await embedTexts(userId, chunks); + await db.insert(messageChunks).values( + chunks.map((c, i) => ({ + messageId, + chunkIndex: i, + content: c, + embedding: embeddings[i], + })) + ); + } catch (err) { + if (err instanceof NoEmbeddingProviderError) return; + // Autres erreurs (réseau, quota embeddings…) : on n'interrompt pas le chat. + } +} diff --git a/src/lib/usage/quota.ts b/src/lib/usage/quota.ts new file mode 100644 index 0000000..ad32f92 --- /dev/null +++ b/src/lib/usage/quota.ts @@ -0,0 +1,52 @@ +import { and, eq, gte } from "drizzle-orm"; +import { db } from "@/db"; +import { conversations, messages, users } from "@/db/schema"; +import { aggregateCosts } from "@/lib/providers/pricing"; + +/** + * Début du mois courant (00:00 heure locale serveur). Borne UNIQUE partagée + * par l'enforcement de quota et l'affichage, pour éviter toute divergence de + * période. + */ +export function currentMonthStart(now: Date = new Date()): Date { + return new Date(now.getFullYear(), now.getMonth(), 1); +} + +/** + * Dépense IA du mois courant en centimes. SOURCE UNIQUE utilisée à la fois par + * l'enforcement de quota (/api/chat) ET par l'affichage (page usage, dashboard) + * pour garantir que le montant montré au membre == celui qui déclenche le + * blocage 402. Convention identique à l'enforcement historique : EUR et USD + * additionnés 1:1 — imprécis sur la devise, mais c'est la vérité du plafond. + */ +export async function getMonthlySpendCents(userId: string): Promise { + const rows = await db + .select({ + modelId: messages.modelId, + inputTokens: messages.inputTokens, + outputTokens: messages.outputTokens, + }) + .from(messages) + .innerJoin(conversations, eq(conversations.id, messages.conversationId)) + .where( + and( + eq(conversations.userId, userId), + eq(messages.role, "assistant"), + gte(messages.createdAt, currentMonthStart()) + ) + ); + const totals = aggregateCosts(rows); + return Math.round((totals.EUR + totals.USD) * 100); +} + +/** Plafond mensuel (centimes) défini par l'admin, ou null si aucun. */ +export async function getUserMonthlyQuotaCents( + userId: string +): Promise { + const [row] = await db + .select({ monthlyQuotaCents: users.monthlyQuotaCents }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + return row?.monthlyQuotaCents ?? null; +}