Compare commits
16
Commits
+12
-7
@@ -58,7 +58,7 @@ Pour une chip non cliquable (affichage seul, ex. liste de résultats) : `as="spa
|
|||||||
|
|
||||||
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` (depuis B11, `MiniFiche` porte aussi `etiquettes`, `chipsDimension`, `coords`, `date`, `ville`, `texte` : voir §Vue fiches) — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
|
`components/FicheMiniCard.vue` consomme uniquement `MiniFiche` (depuis B11, `MiniFiche` porte aussi `etiquettes`, `chipsDimension`, `coords`, `date`, `ville`, `texte` : voir §Vue fiches) — il n'a jamais besoin de connaître `Org` ou `StructureV2`. C'est ce découplage qui permet d'ajouter une source sans toucher au composant d'affichage.
|
||||||
|
|
||||||
## Vue fiches (B11, 02/10/2026) — `VueFiches` + `useVueFiches`
|
## Vue fiches (B11 02/10/2026, détail B12 03/10/2026) — `VueFiches` + `useVueFiches`
|
||||||
|
|
||||||
Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitain / Outre-mer / Toutes les fiches de `/` et `/agences` (tous retirés). Spécification : `1 PROJETS/DEV/AEP/Cadrage/SPEC-vue-fiches.md` (vault).
|
Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitain / Outre-mer / Toutes les fiches de `/` et `/agences` (tous retirés). Spécification : `1 PROJETS/DEV/AEP/Cadrage/SPEC-vue-fiches.md` (vault).
|
||||||
|
|
||||||
@@ -66,11 +66,11 @@ Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitai
|
|||||||
|
|
||||||
| Fichier | Rôle | Testé par |
|
| Fichier | Rôle | Testé par |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `utils/vueFiches.ts` | logique pure : `lireQuery` / `ecrireQuery`, `filtrerFiches`, `compterEtiquettes`, `trierFiches`, `regrouper`, `ordreFiches` | `node scripts/test-vue-fiches.mjs` |
|
| `utils/vueFiches.ts` | logique pure : `lireQuery` / `ecrireQuery`, `filtrerFiches`, `compterEtiquettes`, `trierFiches`, `regrouper`, `ordreFiches` ; B12 : `lireFiche`, `queryAvecFiche`, `positionFiche`, `modeOuverture` / `modeFermeture`, `empreinteListe` | `node scripts/test-vue-fiches.mjs` |
|
||||||
| `composables/useVueFiches.ts` | branche la logique sur la route ; rend un objet `reactive` (`vf`) | navigateur |
|
| `composables/useVueFiches.ts` | branche la logique sur la route ; rend un objet `reactive` (`vf`) | navigateur |
|
||||||
| `components/VueFiches.vue` (+ `VueFichesFiltres.vue`) | barre d'outils, trois états, filtres, grille, poignée, feuille « Filtrer » | navigateur |
|
| `components/VueFiches.vue` (+ `VueFichesFiltres.vue`) | barre d'outils, trois états, filtres, grille, poignée, feuille « Filtrer », panneau de détail (B12) | navigateur |
|
||||||
|
|
||||||
**L'URL est la source de vérité.** L'état se lit dans `route.query` (surveillée) et s'écrit par `router.replace`. Paramètres : `vue` (`carte` · `mixte` · `fiches`, absent = défaut par largeur), `q`, un paramètre par dimension (valeurs séparées par des virgules), `adresse=sans`, `tri`, `groupe`, et celui des sous-vues de carte (`mode` sur `/`, `carte` sur `/agences`). Un paramètre inconnu ou invalide est ignoré ; un paramètre que la vue ne gère pas (`territoire`, `random`, le futur `fiche=` de B12) est recopié tel quel. La recherche part dans l'URL 250 ms après la dernière touche.
|
**L'URL est la source de vérité.** L'état se lit dans `route.query` (surveillée) et s'écrit par `router.replace`. Paramètres : `vue` (`carte` · `mixte` · `fiches`, absent = défaut par largeur), `q`, un paramètre par dimension (valeurs séparées par des virgules), `adresse=sans`, `tri`, `groupe`, et celui des sous-vues de carte (`mode` sur `/`, `carte` sur `/agences`). Un paramètre inconnu ou invalide est ignoré ; un paramètre que la vue ne gère pas (`territoire`, `random`) est recopié tel quel, `fiche=` aussi (un filtre changé garde la fiche ouverte). La recherche part dans l'URL 250 ms après la dernière touche.
|
||||||
|
|
||||||
**États.** Sans `vue=` : Mixte à partir de 1024 px, Fiches en dessous. Mixte n'existe pas sous 1024 px (il se lit Fiches). Avant le montage, la racine porte `vf--auto` et la CSS tranche seule, pour qu'un lien s'affiche sans saut. La poignée ‹ › bascule Mixte ↔ Carte ; le repli est retenu en `sessionStorage` (`aep_vue_fiches_liste_repliee`) et ne compte que si l'URL n'a pas de `vue=`. En état Fiches, la carte reste montée, invisible (Leaflet garde une taille) ; les cartes appellent `invalidateSize` par `ResizeObserver`.
|
**États.** Sans `vue=` : Mixte à partir de 1024 px, Fiches en dessous. Mixte n'existe pas sous 1024 px (il se lit Fiches). Avant le montage, la racine porte `vf--auto` et la CSS tranche seule, pour qu'un lien s'affiche sans saut. La poignée ‹ › bascule Mixte ↔ Carte ; le repli est retenu en `sessionStorage` (`aep_vue_fiches_liste_repliee`) et ne compte que si l'URL n'a pas de `vue=`. En état Fiches, la carte reste montée, invisible (Leaflet garde une taille) ; les cartes appellent `invalidateSize` par `ResizeObserver`.
|
||||||
|
|
||||||
@@ -78,22 +78,27 @@ Remplace `FichesPanel`, `NavSidebar`, `MobileSheet` et les onglets Métropolitai
|
|||||||
|
|
||||||
**Tri** : `pertinence` (nombre d'étiquettes cochées, puis nom ; défaut dès qu'un filtre est actif), `nom` (défaut sinon), `recent` (date puis Id décroissant ; à n'offrir que si la source a une date). **Regroupement** : une fiche va sous sa PREMIÈRE étiquette de la dimension, jamais en double.
|
**Tri** : `pertinence` (nombre d'étiquettes cochées, puis nom ; défaut dès qu'un filtre est actif), `nom` (défaut sinon), `recent` (date puis Id décroissant ; à n'offrir que si la source a une date). **Regroupement** : une fiche va sous sa PREMIÈRE étiquette de la dimension, jamais en double.
|
||||||
|
|
||||||
|
**Détail et fiche à fiche (B12).** Cliquer une carte-fiche (ou une épingle : la page appelle `vf.ouvrirFiche(id)`) écrit `fiche=<id>` par `router.push` ; passer d'une fiche à l'autre (Précédente / Suivante, ← →, balayage au téléphone) par `router.replace`. Fermer (✕, « ‹ N fiches », Échap) revient en arrière si l'entrée vient de la page et que la liste n'a pas changé depuis, sinon retire `fiche=` sur place (lien partagé, filtre changé fiche ouverte) : le retour du téléphone ferme la fiche, jamais la page. La fiche est cherchée dans TOUTES les fiches : hors du résultat, le panneau dit « Hors de vos filtres » et n'a pas de Précédente / Suivante. Panneau de 380 px en troisième colonne (ordinateur, dans les trois états ; la grille Fiches passe à 2 colonnes), 340 px sur tablette, plein écran par-dessus la vue au téléphone (barre, liste et carte en `inert`, fiche à fiche en pied). Le focus entre dans le panneau à l'ouverture et revient, à la fermeture, sur la carte-fiche de la dernière fiche lue ; la position de défilement de la liste est rendue. Actions du panneau : Ouvrir la page de la fiche (prop `lien-page`), Voir sur la carte (si la fiche a une épingle dans le résultat : Mixte sur ordinateur, Carte au téléphone ; la page lit `vf.centrage` et le passe à sa carte, prop `centrage` de `NavMap` / `NavMapV2`), Copier le lien (l'URL courante dit tout). Deux écritures d'URL dans le même tick s'enchaînent (la seconde part de la query en attente), elles ne s'écrasent plus.
|
||||||
|
|
||||||
**Contrat.**
|
**Contrat.**
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<VueFiches :vf="vf" intention="Une ligne." libelle-recherche="Rechercher…" :pending="pending"
|
<VueFiches :vf="vf" intention="Une ligne." libelle-recherche="Rechercher…" :pending="pending"
|
||||||
:selected-id="selectedId" @select="(fiche) => ouvrir(fiche)" @survol="…">
|
:lien-page="(f) => `/fiche/${f.id}`" @survol="…">
|
||||||
<template #carte> <!-- carte(s) de la page, une par sous-vue, v-show sur vf.etat.sousVue --> </template>
|
<template #carte> <!-- carte(s) de la page, une par sous-vue, v-show sur vf.etat.sousVue --> </template>
|
||||||
|
<template #detail="{ fiche, filtrer }"> <!-- contenu propre à la source ; filtrer(dimension, valeur) au clic d'une étiquette --> </template>
|
||||||
<template #vide> <!-- optionnel --> </template>
|
<template #vide> <!-- optionnel --> </template>
|
||||||
</VueFiches>
|
</VueFiches>
|
||||||
```
|
```
|
||||||
|
|
||||||
`const vf = useVueFiches(fiches, config)` : `fiches` = `MiniFiche[]` déjà adaptées, non filtrées (ref, computed ou getter) ; `config: ConfigVueFiches` = `{ dimensions, tris, groupes, etats, sousVues? }` (types dans `utils/vueFiches.ts`). Le composant ne charge rien et ne connaît pas la carte. La page lit `vf.idsResultat` pour ne montrer sur la carte que le résultat courant, `vf.survolId` pour allumer l'épingle (et l'écrit au survol d'une épingle), `vf.ordre` pour le fiche à fiche (B12), `vf.largeur` et `vf.vueAffichee` pour placer ses boutons flottants.
|
Contenus de détail en place : `/` → `FicheDetail` (`compact`, `etiquettes-cliquables`) + commentaires, l'`Org` venant de la liste (même enregistrement NocoDB que `/api/fiche/:id`) ; `/agences` → `FicheReseauContenu` (corps de l'ancienne `FicheModalV2`, une structure liée s'ouvre dans le même panneau). `FicheModal` et `FicheModalV2` sont retirées ; la page `/fiche/:id` reste.
|
||||||
|
|
||||||
|
`const vf = useVueFiches(fiches, config)` : `fiches` = `MiniFiche[]` déjà adaptées, non filtrées (ref, computed ou getter) ; `config: ConfigVueFiches` = `{ dimensions, tris, groupes, etats, sousVues? }` (types dans `utils/vueFiches.ts`). Le composant ne charge rien et ne connaît pas la carte. La page lit `vf.idsResultat` pour ne montrer sur la carte que le résultat courant, `vf.survolId` pour allumer l'épingle (et l'écrit au survol d'une épingle), `vf.ficheId` / `vf.ficheOuverte` / `vf.position` pour la fiche ouverte (B12), `vf.largeur` et `vf.vueAffichee` pour placer ses boutons flottants.
|
||||||
|
|
||||||
**Brancher une troisième page** (Codev en B12, Outils, vue Œuvres) :
|
**Brancher une troisième page** (Codev en B12, Outils, vue Œuvres) :
|
||||||
1. Adaptateur dans `useFicheAdapter.ts` : remplir `etiquettes` avec une clé par dimension (= nom du paramètre d'URL), `chipsDimension`, `coords` si la source en a (sinon `geoloc: true`).
|
1. Adaptateur dans `useFicheAdapter.ts` : remplir `etiquettes` avec une clé par dimension (= nom du paramètre d'URL), `chipsDimension`, `coords` si la source en a (sinon `geoloc: true`).
|
||||||
2. Une `ConfigVueFiches` dans la page : dimensions (variante `fonction` pour la principale, `echelle` pour la secondaire), tris (pas de `recent` sans date), groupes, `etats` (`['carte', 'fiches']` si la page n'a pas la place pour Mixte), `sousVues` si la carte en a.
|
2. Une `ConfigVueFiches` dans la page : dimensions (variante `fonction` pour la principale, `echelle` pour la secondaire), tris (pas de `recent` sans date), groupes, `etats` (`['carte', 'fiches']` si la page n'a pas la place pour Mixte), `sousVues` si la carte en a.
|
||||||
3. Le slot `#carte` ; `@select` garde le comportement d'ouverture propre à la page (modale, page `/fiche/:id`…).
|
3. Le slot `#carte`, et le slot `#detail` (contenu de la fiche ; une page sans contenu de détail affiche seulement les actions). Les épingles appellent `vf.ouvrirFiche(id)`.
|
||||||
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
|
4. Ajouter des cas à `scripts/test-vue-fiches.mjs` si la page introduit une règle nouvelle.
|
||||||
|
|
||||||
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
|
**`FicheMiniCard`** : racine `<article>`, toute la carte ouvre la fiche par un bouton étiré (`::after`) ; avec `etiquettes-cliquables`, les chips deviennent des boutons (impossible dans l'ancienne racine `<button>`). Props `actif` (liseré safran au survol d'épingle), `etiquettes-actives`. Résumé et « Sans localisation » en `color-mix(--text 78 %)` : `--text-muted` et `--text-subtle` échouaient au contraste.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<template>
|
||||||
|
<span v-if="profondeur === 'approfondi'" class="chat-mode-badge">{{ CHAT_PROFONDEUR_LIBELLES.approfondi }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Étiquette sobre sur une réponse donnée en mode approfondi (rien en mode rapide).
|
||||||
|
defineProps<{ profondeur?: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-mode-badge {
|
||||||
|
display: inline-block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
padding: 1px 8px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid var(--nav-bg-alt);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<template>
|
||||||
|
<div class="chat-mode" role="radiogroup" aria-label="Mode de réponse">
|
||||||
|
<button
|
||||||
|
v-for="(p, i) in CHAT_PROFONDEURS"
|
||||||
|
:key="p"
|
||||||
|
:ref="(el) => (boutons[i] = el as HTMLButtonElement | null)"
|
||||||
|
type="button"
|
||||||
|
role="radio"
|
||||||
|
class="chat-mode-option"
|
||||||
|
:class="{ active: modelValue === p }"
|
||||||
|
:aria-checked="modelValue === p"
|
||||||
|
:tabindex="modelValue === p ? 0 : -1"
|
||||||
|
:title="CHAT_PROFONDEUR_AIDES[p]"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="choisir(p)"
|
||||||
|
@keydown="onKey($event, i)"
|
||||||
|
>{{ CHAT_PROFONDEUR_LIBELLES[p] }}</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Toggle 2 options partagé par les 5 chatbots. Libellés : utils/chatProfondeur.ts.
|
||||||
|
// radiogroup : flèches déplacent la sélection, Tab entre/sort du groupe (roving tabindex).
|
||||||
|
const props = defineProps<{ modelValue: 'rapide' | 'approfondi'; disabled?: boolean }>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [value: 'rapide' | 'approfondi'] }>()
|
||||||
|
|
||||||
|
const boutons = ref<(HTMLButtonElement | null)[]>([])
|
||||||
|
|
||||||
|
function choisir(p: ChatProfondeur) {
|
||||||
|
if (!props.disabled) emit('update:modelValue', p)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKey(e: KeyboardEvent, i: number) {
|
||||||
|
const n = CHAT_PROFONDEURS.length
|
||||||
|
let j = -1
|
||||||
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') j = (i + 1) % n
|
||||||
|
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') j = (i - 1 + n) % n
|
||||||
|
else if (e.key === 'Home') j = 0
|
||||||
|
else if (e.key === 'End') j = n - 1
|
||||||
|
if (j < 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
choisir(CHAT_PROFONDEURS[j])
|
||||||
|
nextTick(() => boutons.value[j]?.focus())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-mode {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 2px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
background: var(--nav-bg-alt);
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.chat-mode-option {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.chat-mode-option:hover:not(.active):not(:disabled) { color: var(--nav-text); }
|
||||||
|
.chat-mode-option.active {
|
||||||
|
background: var(--nav-primary);
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.chat-mode-option:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
|
||||||
|
.chat-mode-option:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.chat-mode-option { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<template>
|
||||||
|
<button type="button" class="chat-new-conversation" @click="emit('reset')">
|
||||||
|
Nouvelle conversation
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Bouton sobre « Nouvelle conversation », partagé par les chatbots.
|
||||||
|
// Le parent le rend seulement s'il y a un historique et gère la purge.
|
||||||
|
const emit = defineEmits<{ reset: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chat-new-conversation {
|
||||||
|
align-self: flex-end;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--nav-font);
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chat-new-conversation:hover { color: var(--nav-text); background: var(--nav-bg-alt); }
|
||||||
|
.chat-new-conversation:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
|
||||||
|
</style>
|
||||||
+161
-31
@@ -16,16 +16,6 @@
|
|||||||
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
<span v-else class="chatbot-corpus-badge">FRACAS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="chatbot-header-right">
|
<div class="chatbot-header-right">
|
||||||
<button
|
|
||||||
v-if="messages.length > 0"
|
|
||||||
class="chatbot-clear-btn"
|
|
||||||
title="Effacer la conversation"
|
|
||||||
@click="clearMessages"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
<select v-model="corpusMode" class="chatbot-corpus-select" title="Corpus interrogé">
|
||||||
<option value="pensees">Pensées éco.</option>
|
<option value="pensees">Pensées éco.</option>
|
||||||
<option value="both">Pensées + Projets</option>
|
<option value="both">Pensées + Projets</option>
|
||||||
@@ -52,6 +42,7 @@
|
|||||||
<template v-for="(msg, i) in messages" :key="i">
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
|
<div v-if="msg.role === 'user'" class="chatbot-bubble user">{{ msg.content }}</div>
|
||||||
<div v-else class="chatbot-bubble assistant">
|
<div v-else class="chatbot-bubble assistant">
|
||||||
|
<ChatModeBadge :profondeur="msg.profondeur" />
|
||||||
<div class="chatbot-md" v-html="renderMd(msg.content)" />
|
<div class="chatbot-md" v-html="renderMd(msg.content)" />
|
||||||
<div v-if="msg.auteur" class="chatbot-auteur-tag">
|
<div v-if="msg.auteur" class="chatbot-auteur-tag">
|
||||||
<span>{{ msg.auteur.nom }}</span>
|
<span>{{ msg.auteur.nom }}</span>
|
||||||
@@ -59,13 +50,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Chargement -->
|
<!-- Chargement : texte d'attente progressif. L'annonce aux lecteurs d'écran passe par la région
|
||||||
<div v-if="loading" class="chatbot-bubble assistant loading">
|
live persistante ci-dessous (une région insérée à chaud n'est pas fiablement annoncée). -->
|
||||||
|
<div v-if="loading" class="chatbot-bubble assistant loading" aria-hidden="true">
|
||||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
<span class="attente-texte">{{ attenteTexte }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sr-only" role="status" aria-live="polite" aria-atomic="true">{{ loading ? attenteTexte : '' }}</div>
|
||||||
|
|
||||||
<!-- Erreur -->
|
<!-- Erreur -->
|
||||||
<div v-if="errorMsg" class="chatbot-error">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="chatbot-error" role="alert">
|
||||||
|
<span>{{ errorMsg }}</span>
|
||||||
|
<button v-if="peutReessayer" type="button" class="chatbot-retry-btn" @click="reessayer">Réessayer</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="clearMessages" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode de réponse -->
|
||||||
|
<div class="chatbot-mode-row">
|
||||||
|
<ChatModeToggle v-model="profondeur" :disabled="loading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -102,6 +106,7 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
auteur?: { slug: string; nom: string } | null
|
auteur?: { slug: string; nom: string } | null
|
||||||
|
profondeur?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -109,21 +114,78 @@ const props = defineProps<{
|
|||||||
inline?: boolean
|
inline?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
// Une conversation par contexte (corpus général ou un auteur) : changer d'auteur
|
||||||
|
// ne mélange pas les historiques, et un rechargement restaure le bon.
|
||||||
|
const sessionKey = computed(() => props.auteurContext ? `pensees-${slugify(props.auteurContext)}` : 'pensees')
|
||||||
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(sessionKey)
|
||||||
|
// `profondeur` (et non `mode`, réservé au mode de recherche LightRAG côté serveur)
|
||||||
|
const profondeur = useChatProfondeur('pensees')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
const corpusMode = ref<'pensees' | 'projets' | 'both'>('pensees')
|
||||||
const messagesContainer = ref<HTMLElement | null>(null)
|
const messagesContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
watch(() => props.auteurContext, () => {
|
// --- Attente et erreurs : état d'interface uniquement, jamais persisté (hors useChatSession) ---
|
||||||
messages.value = []
|
// Mesuré le 09/10 : récupération 2-5 s, génération 10-18 s, 19-29 s bout en bout. Les seuils suivent ces
|
||||||
|
// phases ; le texte est une minuterie (le serveur renvoie tout d'un bloc : il faut la réponse complète
|
||||||
|
// pour vérifier les citations, donc pas de flux de texte).
|
||||||
|
const SEUIL_REDIGE_S = 6
|
||||||
|
const SEUIL_LONG_S = 30
|
||||||
|
// Au-delà du timeout serveur (90 s) : le front ne lâche jamais avant la route, et ne tourne jamais sans fin.
|
||||||
|
const TIMEOUT_FRONT_MS = 95000
|
||||||
|
|
||||||
|
const attenteS = ref(0)
|
||||||
|
const attenteTexte = computed(() => {
|
||||||
|
if (attenteS.value >= SEUIL_LONG_S) return 'Le corpus est vaste, encore un instant…'
|
||||||
|
if (attenteS.value >= SEUIL_REDIGE_S) return 'Je rédige…'
|
||||||
|
return 'Je fouille le corpus…'
|
||||||
|
})
|
||||||
|
let attenteTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
function demarrerAttente() {
|
||||||
|
arreterAttente()
|
||||||
|
attenteS.value = 0
|
||||||
|
const t0 = Date.now()
|
||||||
|
attenteTimer = setInterval(() => { attenteS.value = Math.floor((Date.now() - t0) / 1000) }, 1000)
|
||||||
|
}
|
||||||
|
function arreterAttente() {
|
||||||
|
if (attenteTimer) { clearInterval(attenteTimer); attenteTimer = null }
|
||||||
|
}
|
||||||
|
onBeforeUnmount(arreterAttente)
|
||||||
|
|
||||||
|
interface Envoi { query: string; profondeur: string; corpus: string; auteurSlug?: string }
|
||||||
|
const dernierEnvoi = ref<Envoi | null>(null)
|
||||||
|
const peutReessayer = ref(false)
|
||||||
|
|
||||||
|
watch(sessionKey, () => {
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
|
peutReessayer.value = false
|
||||||
|
dernierEnvoi.value = null
|
||||||
})
|
})
|
||||||
|
|
||||||
function clearMessages() {
|
function clearMessages() {
|
||||||
messages.value = []
|
clear()
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
|
peutReessayer.value = false
|
||||||
|
dernierEnvoi.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function messageErreur(e: any): { texte: string; reessayer: boolean } {
|
||||||
|
const status = e?.statusCode ?? e?.status ?? e?.response?.status
|
||||||
|
const detail = String(e?.data?.message ?? e?.statusMessage ?? e?.message ?? '')
|
||||||
|
if (status === 429) {
|
||||||
|
// 429 de la route : quota du jour (pas de réessai utile) ou RAG saturé (réessai possible)
|
||||||
|
if (/satur/i.test(detail)) return { texte: 'Le corpus est très sollicité en ce moment. Patiente quelques secondes, puis réessaie.', reessayer: true }
|
||||||
|
return { texte: 'Tu as atteint la limite de 20 questions par jour. Reviens demain, ou explore les fiches auteurs en attendant.', reessayer: false }
|
||||||
|
}
|
||||||
|
if (status === 503) return { texte: 'La bibliothèque est indisponible pour l\'instant. Réessaie dans quelques minutes.', reessayer: true }
|
||||||
|
if (status === 504) return { texte: 'La bibliothèque met trop de temps à répondre : la question est peut-être trop large. Réessaie, ou reformule-la plus précisément.', reessayer: true }
|
||||||
|
if (!status && /timeout|timed out|abort/i.test(`${detail} ${e?.name ?? ''} ${e?.cause?.name ?? ''}`)) {
|
||||||
|
return { texte: 'Désolé, la réponse met plus de temps que prévu et n\'a pas abouti. Tu peux relancer la même question.', reessayer: true }
|
||||||
|
}
|
||||||
|
if (!status) return { texte: 'La connexion semble coupée : la question n\'est pas partie. Vérifie ton réseau puis réessaie.', reessayer: true }
|
||||||
|
return { texte: 'Une erreur est survenue. Réessaie dans quelques instants.', reessayer: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
@@ -137,9 +199,27 @@ async function sendMessage() {
|
|||||||
if (!query || loading.value) return
|
if (!query || loading.value) return
|
||||||
|
|
||||||
inputText.value = ''
|
inputText.value = ''
|
||||||
errorMsg.value = ''
|
|
||||||
messages.value.push({ role: 'user', content: query })
|
messages.value.push({ role: 'user', content: query })
|
||||||
|
await envoyer({
|
||||||
|
query,
|
||||||
|
profondeur: profondeur.value,
|
||||||
|
corpus: corpusMode.value,
|
||||||
|
auteurSlug: props.auteurContext ? slugify(props.auteurContext) : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reposte la même question (même mode, même corpus) sans dupliquer le message de l'usager.
|
||||||
|
async function reessayer() {
|
||||||
|
if (!dernierEnvoi.value || loading.value) return
|
||||||
|
await envoyer(dernierEnvoi.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function envoyer(envoi: Envoi) {
|
||||||
|
errorMsg.value = ''
|
||||||
|
peutReessayer.value = false
|
||||||
|
dernierEnvoi.value = envoi
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
demarrerAttente()
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -150,29 +230,27 @@ async function sendMessage() {
|
|||||||
auteur: { slug: string; nom: string } | null
|
auteur: { slug: string; nom: string } | null
|
||||||
}>('/api/chatbot-pensees', {
|
}>('/api/chatbot-pensees', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
timeout: TIMEOUT_FRONT_MS,
|
||||||
body: {
|
body: {
|
||||||
query,
|
query: envoi.query,
|
||||||
corpus: corpusMode.value,
|
corpus: envoi.corpus,
|
||||||
auteur_slug: props.auteurContext ? slugify(props.auteurContext) : undefined,
|
profondeur: envoi.profondeur,
|
||||||
|
auteur_slug: envoi.auteurSlug,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
messages.value.push({
|
messages.value.push({
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.response,
|
content: res.response,
|
||||||
auteur: res.auteur ?? null,
|
auteur: res.auteur ?? null,
|
||||||
|
profondeur: envoi.profondeur,
|
||||||
})
|
})
|
||||||
|
dernierEnvoi.value = null
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const status = e?.statusCode ?? e?.status
|
const { texte, reessayer: ok } = messageErreur(e)
|
||||||
if (status === 429) {
|
errorMsg.value = texte
|
||||||
errorMsg.value = 'Limite de 20 questions par jour atteinte.'
|
peutReessayer.value = ok
|
||||||
} else if (status === 503) {
|
|
||||||
errorMsg.value = 'Le RAG est indisponible pour l\'instant — réessaie dans quelques minutes.'
|
|
||||||
} else if (status === 504) {
|
|
||||||
errorMsg.value = 'Le RAG met du temps à répondre — réessaie dans quelques secondes.'
|
|
||||||
} else {
|
|
||||||
errorMsg.value = 'Une erreur est survenue. Réessaie dans quelques instants.'
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
|
arreterAttente()
|
||||||
loading.value = false
|
loading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -361,6 +439,25 @@ function slugify(str: string): string {
|
|||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attente-texte {
|
||||||
|
margin-left: 6px;
|
||||||
|
font-size: 0.775rem;
|
||||||
|
color: var(--nav-text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.dot {
|
.dot {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
@@ -377,6 +474,11 @@ function slugify(str: string): string {
|
|||||||
40% { opacity: 1; }
|
40% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pas d'animation si l'usager la réduit : points fixes, le texte d'attente porte l'information */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.dot { animation: none; opacity: 0.6; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Erreur */
|
/* Erreur */
|
||||||
.chatbot-error {
|
.chatbot-error {
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
@@ -386,8 +488,36 @@ function slugify(str: string): string {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
max-width: 90%;
|
max-width: 90%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatbot-retry-btn {
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--nav-text-on-primary);
|
||||||
|
background: var(--nav-primary);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.chatbot-retry-btn:hover { opacity: 0.85; }
|
||||||
|
.chatbot-retry-btn:focus-visible { outline: 2px solid var(--nav-primary); outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* Mode de réponse */
|
||||||
|
.chatbot-mode-row {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 10px 0;
|
||||||
|
border-top: 1px solid rgba(180, 170, 160, 0.22);
|
||||||
|
background: var(--nav-surface);
|
||||||
|
}
|
||||||
|
.chatbot-mode-row + .chatbot-input-row { border-top: none; }
|
||||||
|
|
||||||
/* Input */
|
/* Input */
|
||||||
.chatbot-input-row {
|
.chatbot-input-row {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
:aria-expanded="isExpanded"
|
:aria-expanded="isExpanded"
|
||||||
aria-label="Ouvrir l'assistant chatbot"
|
:aria-label="`Ouvrir l'assistant : ${titre}`"
|
||||||
@keydown.enter="toggleExpand"
|
@keydown.enter="toggleExpand"
|
||||||
@keydown.space.prevent="toggleExpand"
|
@keydown.space.prevent="toggleExpand"
|
||||||
>
|
>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
<span class="flex-1 text-sm" style="color: var(--nav-text-muted);">
|
||||||
{{ isExpanded ? 'Chatbot AEP' : 'Pose une question sur le réseau…' }}
|
{{ isExpanded ? titre : placeholderReplie }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Chevron -->
|
<!-- Chevron -->
|
||||||
@@ -52,21 +52,23 @@
|
|||||||
<div class="chatbot-body-inner" ref="messagesContainer">
|
<div class="chatbot-body-inner" ref="messagesContainer">
|
||||||
<!-- Onboarding -->
|
<!-- Onboarding -->
|
||||||
<div v-if="messages.length === 0" class="onboarding-bubble">
|
<div v-if="messages.length === 0" class="onboarding-bubble">
|
||||||
<p>Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.</p>
|
<p>{{ intro }}</p>
|
||||||
<p class="example">Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."</p>
|
<p v-if="exemple" class="example">{{ exemple }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Messages -->
|
<!-- Messages -->
|
||||||
<template v-for="(msg, i) in messages" :key="i">
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
<p>{{ msg.content }}</p>
|
<ChatModeBadge :profondeur="msg.profondeur" />
|
||||||
|
<div v-if="rendreMarkdown" v-html="renderMd(msg.content)" />
|
||||||
|
<p v-else>{{ msg.content }}</p>
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list">
|
||||||
<p class="fiches-title">Fiches recommandees :</p>
|
<p class="fiches-title">{{ fichesTitre }}</p>
|
||||||
<a
|
<a
|
||||||
v-for="fiche in msg.fiches"
|
v-for="fiche in msg.fiches"
|
||||||
:key="fiche.id"
|
:key="fiche.id"
|
||||||
:href="`/fiche/${fiche.id}`"
|
:href="ficheLienModele.replace('{id}', String(fiche.id))"
|
||||||
class="fiche-card"
|
class="fiche-card"
|
||||||
>
|
>
|
||||||
<span class="fiche-nom">{{ fiche.nom }}</span>
|
<span class="fiche-nom">{{ fiche.nom }}</span>
|
||||||
@@ -98,15 +100,22 @@
|
|||||||
|
|
||||||
<!-- Erreur -->
|
<!-- Erreur -->
|
||||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode de réponse -->
|
||||||
|
<div class="chatbot-mode-row" style="border-top: 1px solid var(--nav-bg-alt);">
|
||||||
|
<ChatModeToggle v-model="profondeur" :disabled="loading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
<div class="chatbot-input-row" style="border-top: 1px solid var(--nav-bg-alt);">
|
<div class="chatbot-input-row">
|
||||||
<input
|
<input
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
type="text"
|
type="text"
|
||||||
:disabled="loading"
|
:disabled="loading"
|
||||||
placeholder="Pose ta question…"
|
:placeholder="placeholderSaisie"
|
||||||
class="chatbot-input"
|
class="chatbot-input"
|
||||||
@keydown.enter.prevent="sendMessage"
|
@keydown.enter.prevent="sendMessage"
|
||||||
/>
|
/>
|
||||||
@@ -127,6 +136,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useMarkdown } from '~/composables/useMarkdown'
|
||||||
|
const { render: renderMd } = useMarkdown()
|
||||||
|
|
||||||
interface FicheReco {
|
interface FicheReco {
|
||||||
id: number | string
|
id: number | string
|
||||||
nom: string
|
nom: string
|
||||||
@@ -138,15 +150,58 @@ interface ChatMessage {
|
|||||||
content: string
|
content: string
|
||||||
fiches?: FicheReco[]
|
fiches?: FicheReco[]
|
||||||
suggestedHashtags?: string[]
|
suggestedHashtags?: string[]
|
||||||
|
profondeur?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Composant unique pour les deux chatbots desktop. Les défauts reproduisent
|
||||||
|
// exactement le comportement de l'accueil (/api/chatbot, backend Entraide).
|
||||||
|
// Les deux backends renvoient { reponse_texte, fiches_recommandees } ; seul
|
||||||
|
// le rendu diffère (texte brut vs markdown, lien de fiche).
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
/** Route serveur appelée (body { question }) */
|
||||||
|
endpoint?: string
|
||||||
|
/** Titre affiché dans l'en-tête déplié */
|
||||||
|
titre?: string
|
||||||
|
/** Texte de l'en-tête replié */
|
||||||
|
placeholderReplie?: string
|
||||||
|
/** Placeholder du champ de saisie */
|
||||||
|
placeholderSaisie?: string
|
||||||
|
/** Texte d'accueil (première bulle) */
|
||||||
|
intro?: string
|
||||||
|
/** Exemple de question, affiché sous l'accueil */
|
||||||
|
exemple?: string
|
||||||
|
/** Intitulé de la liste de fiches/structures citées */
|
||||||
|
fichesTitre?: string
|
||||||
|
/** Modèle de lien vers une fiche, {id} est remplacé */
|
||||||
|
ficheLienModele?: string
|
||||||
|
/** Rendre la réponse en markdown (backend réseaux) plutôt qu'en texte brut */
|
||||||
|
rendreMarkdown?: boolean
|
||||||
|
/** Message affiché quand la limite quotidienne (429) est atteinte */
|
||||||
|
messageLimite?: string
|
||||||
|
/** Clé de persistance (sessionStorage). Même clé = même conversation (ex. mobile + desktop) */
|
||||||
|
chatKey?: string
|
||||||
|
}>(), {
|
||||||
|
chatKey: 'accueil',
|
||||||
|
endpoint: '/api/chatbot',
|
||||||
|
titre: 'Chatbot AEP',
|
||||||
|
placeholderReplie: 'Pose une question sur le réseau…',
|
||||||
|
placeholderSaisie: 'Pose ta question…',
|
||||||
|
intro: "Explore les 120 structures de la carte par la conversation. Je peux t'aider à trouver des collectifs, agences ou réseaux selon ta situation, ta pratique ou tes inspirations du moment.",
|
||||||
|
exemple: 'Exemple : "Je cherche des acteurs de la rénovation de maisons individuelles en France, plutôt en milieu rural, avec des approches biosourcées ou low-tech."',
|
||||||
|
fichesTitre: 'Fiches recommandees :',
|
||||||
|
ficheLienModele: '/fiche/{id}',
|
||||||
|
rendreMarkdown: false,
|
||||||
|
messageLimite: 'Limite de 10 questions par jour atteinte. Reviens demain.',
|
||||||
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
'applyHashtag': [tag: string]
|
'applyHashtag': [tag: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isExpanded = ref(false)
|
const isExpanded = ref(false)
|
||||||
const messages = ref<ChatMessage[]>([])
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>(() => props.chatKey)
|
||||||
|
const profondeur = useChatProfondeur(() => props.chatKey)
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -183,6 +238,11 @@ function detectHashtagsFromQuery(query: string): string[] {
|
|||||||
return detected.slice(0, 3)
|
return detected.slice(0, 3)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function nouvelleConversation() {
|
||||||
|
clear()
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
function toggleExpand() {
|
function toggleExpand() {
|
||||||
isExpanded.value = !isExpanded.value
|
isExpanded.value = !isExpanded.value
|
||||||
}
|
}
|
||||||
@@ -195,6 +255,7 @@ async function sendMessage() {
|
|||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
messages.value.push({ role: 'user', content: question })
|
messages.value.push({ role: 'user', content: question })
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const mode = profondeur.value
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -203,9 +264,9 @@ async function sendMessage() {
|
|||||||
const res = await $fetch<{
|
const res = await $fetch<{
|
||||||
reponse_texte: string
|
reponse_texte: string
|
||||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
}>('/api/chatbot', {
|
}>(props.endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { question },
|
body: { question, profondeur: mode },
|
||||||
})
|
})
|
||||||
|
|
||||||
const suggestedHashtags = detectHashtagsFromQuery(question)
|
const suggestedHashtags = detectHashtagsFromQuery(question)
|
||||||
@@ -214,6 +275,7 @@ async function sendMessage() {
|
|||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
suggestedHashtags: suggestedHashtags.length ? suggestedHashtags : undefined,
|
||||||
|
profondeur: mode,
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
@@ -223,7 +285,7 @@ async function sendMessage() {
|
|||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const status = e?.statusCode ?? e?.status
|
const status = e?.statusCode ?? e?.status
|
||||||
if (status === 429) {
|
if (status === 429) {
|
||||||
errorMsg.value = 'Limite de 10 questions par jour atteinte. Reviens demain.'
|
errorMsg.value = props.messageLimite
|
||||||
} else if (status === 503) {
|
} else if (status === 503) {
|
||||||
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
errorMsg.value = 'Le budget IA mensuel est épuisé. Soutiens NAV sur Liberapay pour continuer.'
|
||||||
} else {
|
} else {
|
||||||
@@ -305,6 +367,11 @@ function scrollToBottom() {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chatbot-mode-row {
|
||||||
|
display: flex;
|
||||||
|
padding: 8px 12px 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
.chatbot-input-row {
|
.chatbot-input-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
<template v-for="(msg, i) in messages" :key="i">
|
<template v-for="(msg, i) in messages" :key="i">
|
||||||
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
<div v-if="msg.role === 'user'" class="user-bubble">{{ msg.content }}</div>
|
||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
|
<ChatModeBadge :profondeur="msg.profondeur" />
|
||||||
<div v-html="renderMd(msg.content)" />
|
<div v-html="renderMd(msg.content)" />
|
||||||
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
<div v-if="msg.fiches && msg.fiches.length > 0" class="fiches-list" style="margin-top:12px;">
|
||||||
<p class="fiches-title">Structures recommandées :</p>
|
<p class="fiches-title">Structures recommandées :</p>
|
||||||
@@ -74,9 +75,11 @@
|
|||||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
<div v-if="errorMsg" class="error-bubble">{{ errorMsg }}</div>
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
<div class="shrink-0 px-4 pt-3 border-t" style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));">
|
||||||
|
<ChatModeToggle v-model="profondeur" :disabled="loading" class="mb-2" />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
@@ -112,12 +115,13 @@ import { useMarkdown } from '~/composables/useMarkdown'
|
|||||||
const { render: renderMd } = useMarkdown()
|
const { render: renderMd } = useMarkdown()
|
||||||
|
|
||||||
interface FicheReco { id: number | string; nom: string; explication?: string }
|
interface FicheReco { id: number | string; nom: string; explication?: string }
|
||||||
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[] }
|
interface ChatMessage { role: 'user' | 'assistant'; content: string; fiches?: FicheReco[]; profondeur?: string }
|
||||||
|
|
||||||
const props = defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('agences')
|
||||||
|
const profondeur = useChatProfondeur('agences')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -136,6 +140,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function nouvelleConversation() {
|
||||||
|
clear()
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const question = inputText.value.trim()
|
const question = inputText.value.trim()
|
||||||
if (!question || loading.value) return
|
if (!question || loading.value) return
|
||||||
@@ -147,11 +156,12 @@ async function sendMessage() {
|
|||||||
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
if (messagesContainer.value) messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const mode = profondeur.value
|
||||||
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
const res = await $fetch<{ reponse_texte: string; fiches_recommandees: FicheReco[] }>(
|
||||||
'/api/chatbot-reseaux',
|
'/api/chatbot-reseaux',
|
||||||
{ method: 'POST', body: { question } }
|
{ method: 'POST', body: { question, profondeur: mode } }
|
||||||
)
|
)
|
||||||
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [] })
|
messages.value.push({ role: 'assistant', content: res.reponse_texte, fiches: res.fiches_recommandees || [], profondeur: mode })
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
const s = e?.statusCode ?? e?.status
|
const s = e?.statusCode ?? e?.status
|
||||||
errorMsg.value = s === 429
|
errorMsg.value = s === 429
|
||||||
|
|||||||
@@ -87,6 +87,7 @@
|
|||||||
|
|
||||||
<!-- Message assistant -->
|
<!-- Message assistant -->
|
||||||
<div v-else class="assistant-bubble">
|
<div v-else class="assistant-bubble">
|
||||||
|
<ChatModeBadge :profondeur="msg.profondeur" />
|
||||||
<div class="md-content" v-html="renderMd(msg.content)" />
|
<div class="md-content" v-html="renderMd(msg.content)" />
|
||||||
|
|
||||||
<!-- Fiches recommandées -->
|
<!-- Fiches recommandées -->
|
||||||
@@ -114,6 +115,8 @@
|
|||||||
<div v-if="errorMsg" class="error-bubble">
|
<div v-if="errorMsg" class="error-bubble">
|
||||||
{{ errorMsg }}
|
{{ errorMsg }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="hasHistory && !loading" @reset="nouvelleConversation" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -121,6 +124,7 @@
|
|||||||
class="shrink-0 px-4 pt-3 border-t"
|
class="shrink-0 px-4 pt-3 border-t"
|
||||||
style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));"
|
style="border-color: var(--nav-bg-alt); padding-bottom: max(1rem, env(safe-area-inset-bottom));"
|
||||||
>
|
>
|
||||||
|
<ChatModeToggle v-model="profondeur" :disabled="loading" class="mb-2" />
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
v-model="inputText"
|
v-model="inputText"
|
||||||
@@ -172,6 +176,7 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
content: string
|
content: string
|
||||||
fiches?: FicheReco[]
|
fiches?: FicheReco[]
|
||||||
|
profondeur?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
@@ -183,7 +188,8 @@ const emit = defineEmits<{
|
|||||||
'highlightOrgs': [ids: (number | string)[]]
|
'highlightOrgs': [ids: (number | string)[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const messages = ref<ChatMessage[]>([])
|
const { messages, hasHistory, clear } = useChatSession<ChatMessage>('accueil')
|
||||||
|
const profondeur = useChatProfondeur('accueil')
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
@@ -207,6 +213,11 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function nouvelleConversation() {
|
||||||
|
clear()
|
||||||
|
errorMsg.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
async function sendMessage() {
|
async function sendMessage() {
|
||||||
const question = inputText.value.trim()
|
const question = inputText.value.trim()
|
||||||
if (!question || loading.value) return
|
if (!question || loading.value) return
|
||||||
@@ -215,6 +226,7 @@ async function sendMessage() {
|
|||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
messages.value.push({ role: 'user', content: question })
|
messages.value.push({ role: 'user', content: question })
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
const mode = profondeur.value
|
||||||
|
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -225,13 +237,14 @@ async function sendMessage() {
|
|||||||
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
fiches_recommandees: { id: number | string; nom: string; explication: string }[]
|
||||||
}>('/api/chatbot', {
|
}>('/api/chatbot', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { question },
|
body: { question, profondeur: mode },
|
||||||
})
|
})
|
||||||
|
|
||||||
const assistantMsg: ChatMessage = {
|
const assistantMsg: ChatMessage = {
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
fiches: res.fiches_recommandees || [],
|
fiches: res.fiches_recommandees || [],
|
||||||
|
profondeur: mode,
|
||||||
}
|
}
|
||||||
messages.value.push(assistantMsg)
|
messages.value.push(assistantMsg)
|
||||||
|
|
||||||
|
|||||||
+57
-26
@@ -6,12 +6,14 @@
|
|||||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
||||||
>
|
>
|
||||||
<!-- Bandeau titre -->
|
<!-- Bandeau titre -->
|
||||||
<div class="px-6 pt-6 pb-4" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
<div :class="compact ? 'px-4 pt-4 pb-3' : 'px-6 pt-6 pb-4'" style="border-bottom: 1px solid var(--nav-bg-alt);">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-3">
|
<div class="flex flex-col gap-3 mb-3" :class="compact ? '' : 'sm:flex-row sm:items-start sm:justify-between'">
|
||||||
<h1
|
<component
|
||||||
class="text-2xl font-bold leading-snug"
|
:is="compact ? 'h2' : 'h1'"
|
||||||
|
class="font-bold leading-snug"
|
||||||
|
:class="compact ? 'text-xl' : 'text-2xl'"
|
||||||
style="color: var(--nav-text);"
|
style="color: var(--nav-text);"
|
||||||
>{{ org.nom }}</h1>
|
>{{ org.nom }}</component>
|
||||||
|
|
||||||
<a
|
<a
|
||||||
v-if="org.url"
|
v-if="org.url"
|
||||||
@@ -33,18 +35,28 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Méta : échelle + ville -->
|
<!-- Méta : échelle + ville -->
|
||||||
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: var(--nav-text-muted);">
|
<div class="flex flex-wrap items-center gap-2 text-sm mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
|
||||||
<span
|
<span
|
||||||
v-if="org.echelle"
|
v-if="org.echelle"
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
style="background: var(--nav-primary); color: var(--nav-text-on-primary);"
|
style="background: var(--nav-primary-solid); color: var(--nav-text-on-primary);"
|
||||||
>{{ org.echelle }}</span>
|
>{{ org.echelle }}</span>
|
||||||
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: var(--nav-text-muted);">{{ org.territoire }}</span>
|
<span v-if="org.territoire && org.territoire !== 'Métropole'" class="text-xs" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">{{ org.territoire }}</span>
|
||||||
<span v-if="org.localisation_ville" style="color: var(--nav-text-muted);">{{ org.localisation_ville }}</span>
|
<span v-if="org.localisation_ville" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">{{ org.localisation_ville }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags fonction -->
|
<!-- Tags fonction -->
|
||||||
<div v-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
|
<ChipGroup v-if="fonctionTags.length && etiquettesCliquables" :aria-label="`Fonctions de ${org.nom}`">
|
||||||
|
<Chip
|
||||||
|
v-for="tag in fonctionTags"
|
||||||
|
:key="tag"
|
||||||
|
variant="fonction"
|
||||||
|
:label="tag"
|
||||||
|
:aria-label="`Filtrer par ${tag}`"
|
||||||
|
@toggle="emit('etiquette', tag)"
|
||||||
|
/>
|
||||||
|
</ChipGroup>
|
||||||
|
<div v-else-if="fonctionTags.length" class="flex flex-wrap gap-1.5">
|
||||||
<span
|
<span
|
||||||
v-for="tag in fonctionTags"
|
v-for="tag in fonctionTags"
|
||||||
:key="tag"
|
:key="tag"
|
||||||
@@ -55,13 +67,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Corps : description + mini-carte -->
|
<!-- Corps : description + mini-carte -->
|
||||||
<div class="p-6 flex flex-col lg:flex-row gap-6">
|
<div class="flex flex-col gap-6" :class="compact ? 'p-4' : 'p-6 lg:flex-row'">
|
||||||
|
|
||||||
<!-- Descriptions -->
|
<!-- Descriptions -->
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<!-- Description soumise par le contributeur -->
|
<!-- Description soumise par le contributeur -->
|
||||||
<div v-if="descriptionUser" class="mb-4">
|
<div v-if="descriptionUser" class="mb-4">
|
||||||
<p class="text-sm leading-relaxed" style="color: var(--nav-text-muted);">Description communauté</p>
|
<p class="text-sm leading-relaxed" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Description communauté</p>
|
||||||
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
|
<p class="mt-1 leading-relaxed" style="color: var(--nav-text);">{{ descriptionUser }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -70,7 +82,7 @@
|
|||||||
|
|
||||||
<!-- Description enrichie IA -->
|
<!-- Description enrichie IA -->
|
||||||
<div v-if="descriptionEnrichie" class="mb-4">
|
<div v-if="descriptionEnrichie" class="mb-4">
|
||||||
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: var(--nav-text-muted);">
|
<p class="text-sm leading-relaxed flex items-center gap-1.5" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
|
||||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
@@ -88,7 +100,7 @@
|
|||||||
|
|
||||||
<!-- Points clés -->
|
<!-- Points clés -->
|
||||||
<div v-if="pointsCles.length" class="mt-4">
|
<div v-if="pointsCles.length" class="mt-4">
|
||||||
<p class="text-sm font-medium mb-2" style="color: var(--nav-text-muted);">Points clés</p>
|
<p class="text-sm font-medium mb-2" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">Points clés</p>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li
|
<li
|
||||||
v-for="(point, i) in pointsCles"
|
v-for="(point, i) in pointsCles"
|
||||||
@@ -106,15 +118,16 @@
|
|||||||
<!-- Mini-carte Leaflet -->
|
<!-- Mini-carte Leaflet -->
|
||||||
<div
|
<div
|
||||||
v-if="hasCoords"
|
v-if="hasCoords"
|
||||||
class="shrink-0 lg:w-56 xl:w-64 rounded-xl overflow-hidden"
|
class="shrink-0 rounded-xl overflow-hidden"
|
||||||
|
:class="compact ? '' : 'lg:w-56 xl:w-64'"
|
||||||
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
|
style="height: 180px; border: 1px solid var(--nav-bg-alt);"
|
||||||
>
|
>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
<div ref="mapContainer" class="w-full h-full"></div>
|
<div ref="mapContainer" class="w-full h-full" role="img" :aria-label="`Carte : ${org.nom}${org.localisation_ville ? `, ${org.localisation_ville}` : ''}`"></div>
|
||||||
<template #fallback>
|
<template #fallback>
|
||||||
<div
|
<div
|
||||||
class="w-full h-full flex items-center justify-center text-xs"
|
class="w-full h-full flex items-center justify-center text-xs"
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
style="background: var(--nav-bg-alt); color: color-mix(in srgb, var(--nav-text) 78%, transparent);"
|
||||||
>Carte…</div>
|
>Carte…</div>
|
||||||
</template>
|
</template>
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
@@ -140,7 +153,7 @@
|
|||||||
|
|
||||||
<Transition name="report-form">
|
<Transition name="report-form">
|
||||||
<div v-if="reportOpen" class="report-panel">
|
<div v-if="reportOpen" class="report-panel">
|
||||||
<p class="text-xs mb-3" style="color: var(--nav-text-muted);">
|
<p class="text-xs mb-3" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent);">
|
||||||
Tes suggestions seront transmises à l'équipe AEP par email.
|
Tes suggestions seront transmises à l'équipe AEP par email.
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
@@ -171,7 +184,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<p v-if="reportError" class="text-xs" style="color: #e53e3e;">{{ reportError }}</p>
|
<p v-if="reportError" class="text-xs" style="color: #e53e3e;">{{ reportError }}</p>
|
||||||
<p v-if="reportSuccess" class="text-xs" style="color: #38a169;">{{ reportSuccess }}</p>
|
<p v-if="reportSuccess" class="text-xs" style="color: #38a169;">{{ reportSuccess }}</p>
|
||||||
<p class="text-xs" style="color: var(--nav-text-muted); opacity: 0.6;">
|
<p class="text-xs" style="color: color-mix(in srgb, var(--nav-text) 78%, transparent); opacity: 0.6;">
|
||||||
{{ reportMessage.length }}/500
|
{{ reportMessage.length }}/500
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -186,7 +199,15 @@
|
|||||||
const baseTiles = useBaseTiles()
|
const baseTiles = useBaseTiles()
|
||||||
import type { Org } from '~/types/org'
|
import type { Org } from '~/types/org'
|
||||||
|
|
||||||
const props = defineProps<{ org: Org }>()
|
const props = defineProps<{
|
||||||
|
org: Org
|
||||||
|
/** Panneau de détail de la vue fiches (B12, 380 px) : tout en colonne, titre en h2. */
|
||||||
|
compact?: boolean
|
||||||
|
/** Fonctions en chips cliquables : la vue fiches ajoute le filtre. */
|
||||||
|
etiquettesCliquables?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ etiquette: [fonction: string] }>()
|
||||||
|
|
||||||
// ── Champs ──────────────────────────────────────────────────────────────
|
// ── Champs ──────────────────────────────────────────────────────────────
|
||||||
const descriptionUser = computed(() =>
|
const descriptionUser = computed(() =>
|
||||||
@@ -264,15 +285,24 @@ async function submitReport() {
|
|||||||
// ── Mini-carte Leaflet ────────────────────────────────────────────────
|
// ── Mini-carte Leaflet ────────────────────────────────────────────────
|
||||||
const mapContainer = ref<HTMLElement | null>(null)
|
const mapContainer = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
onMounted(async () => {
|
// Le conteneur est dans <ClientOnly>, rendu APRÈS le onMounted de ce composant
|
||||||
if (!hasCoords.value || !mapContainer.value) return
|
// quand la fiche s'ouvre côté client (panneau de la vue fiches, B12) : on
|
||||||
|
// attend qu'il existe au lieu de le lire au montage (il valait null, la
|
||||||
|
// mini-carte restait un cadre vide).
|
||||||
|
let mapInit = false
|
||||||
|
watch(mapContainer, (el) => { if (el) initMap(el) }, { flush: 'post' })
|
||||||
|
onMounted(() => { if (mapContainer.value) initMap(mapContainer.value) })
|
||||||
|
|
||||||
|
async function initMap(el: HTMLElement) {
|
||||||
|
if (mapInit || !hasCoords.value) return
|
||||||
|
mapInit = true
|
||||||
|
|
||||||
const L = (await import('leaflet')).default
|
const L = (await import('leaflet')).default
|
||||||
|
|
||||||
const lat = props.org.latitude as number
|
const lat = props.org.latitude as number
|
||||||
const lng = props.org.longitude as number
|
const lng = props.org.longitude as number
|
||||||
|
|
||||||
const map = L.map(mapContainer.value, {
|
const map = L.map(el, {
|
||||||
center: [lat, lng],
|
center: [lat, lng],
|
||||||
zoom: 10,
|
zoom: 10,
|
||||||
zoomControl: false,
|
zoomControl: false,
|
||||||
@@ -301,8 +331,9 @@ onMounted(async () => {
|
|||||||
iconAnchor: [7, 7],
|
iconAnchor: [7, 7],
|
||||||
})
|
})
|
||||||
|
|
||||||
L.marker([lat, lng], { icon }).addTo(map)
|
// Épingle décorative : ni clic ni clavier (la mini-carte est une image, nommée par son conteneur)
|
||||||
})
|
L.marker([lat, lng], { icon, interactive: false, keyboard: false }).addTo(map)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -312,7 +343,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
color: var(--nav-text-muted);
|
color: color-mix(in srgb, var(--nav-text) 78%, transparent);
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -1,183 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && orgId != null"
|
|
||||||
class="fixed inset-0 z-[1500]"
|
|
||||||
style="background: rgba(26,34,56,0.55);"
|
|
||||||
@click="close"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Modal centré -->
|
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && orgId != null"
|
|
||||||
class="fiche-modal fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
|
||||||
style="
|
|
||||||
width: min(768px, 92vw);
|
|
||||||
background: var(--nav-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
|
||||||
overflow: hidden;
|
|
||||||
"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
:aria-label="org?.nom ?? 'Fiche organisation'"
|
|
||||||
@keydown.esc="close"
|
|
||||||
>
|
|
||||||
<!-- Header modal -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between px-5 py-3 shrink-0 border-b"
|
|
||||||
style="background: var(--nav-surface); border-color: var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">Fiche détaillée</span>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<!-- Lien fiche complète -->
|
|
||||||
<a
|
|
||||||
v-if="orgId"
|
|
||||||
:href="`/fiche/${orgId}`"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Ouvrir
|
|
||||||
</a>
|
|
||||||
<!-- Fermer -->
|
|
||||||
<button
|
|
||||||
@click="close"
|
|
||||||
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
aria-label="Fermer"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contenu scrollable -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
||||||
<!-- Chargement -->
|
|
||||||
<div v-if="pending" class="py-12 text-center text-sm" style="color: var(--nav-text-muted);">
|
|
||||||
Chargement de la fiche…
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Erreur -->
|
|
||||||
<div v-else-if="error" class="py-12 text-center">
|
|
||||||
<p class="text-base font-semibold mb-2" style="color: var(--nav-text);">Fiche introuvable</p>
|
|
||||||
<p class="text-sm" style="color: var(--nav-text-muted);">L'organisation demandée n'existe pas ou a été supprimée.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contenu -->
|
|
||||||
<template v-else-if="org">
|
|
||||||
<FicheDetail :org="org" />
|
|
||||||
<div class="mb-5" style="height: 1px; background: var(--nav-bg-alt);"></div>
|
|
||||||
<CommentSection :org-id="org.Id" :refresh="commentRefreshTick" />
|
|
||||||
<CommentForm :org-id="org.Id" @submitted="onCommentSubmitted" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { Org } from '~/types/org'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
orgId: number | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fermeture Esc globale
|
|
||||||
onMounted(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && props.modelValue) close()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
onUnmounted(() => window.removeEventListener('keydown', handler))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Fetch fiche quand orgId change
|
|
||||||
const org = ref<Org | null>(null)
|
|
||||||
const pending = ref(false)
|
|
||||||
const error = ref(false)
|
|
||||||
|
|
||||||
watch(() => props.orgId, async (id) => {
|
|
||||||
if (id == null) return
|
|
||||||
pending.value = true
|
|
||||||
error.value = false
|
|
||||||
org.value = null
|
|
||||||
try {
|
|
||||||
org.value = await $fetch<Org>(`/api/fiche/${id}`)
|
|
||||||
} catch {
|
|
||||||
error.value = true
|
|
||||||
} finally {
|
|
||||||
pending.value = false
|
|
||||||
}
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
const commentRefreshTick = ref(0)
|
|
||||||
|
|
||||||
function onCommentSubmitted() {
|
|
||||||
commentRefreshTick.value++
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
|
||||||
.fiche-modal {
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.fiche-modal {
|
|
||||||
top: 76px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-height: calc(100dvh - 92px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Backdrop */
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-enter-active, .modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -52%);
|
|
||||||
}
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
transform: translate(-50%, calc(-2% + 76px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
|
||||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<Transition name="backdrop">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && structureId != null"
|
|
||||||
class="fixed inset-0 z-[1500]"
|
|
||||||
style="background: rgba(26,34,56,0.55);"
|
|
||||||
@click="close"
|
|
||||||
aria-hidden="true"
|
|
||||||
/>
|
|
||||||
</Transition>
|
|
||||||
|
|
||||||
<!-- Modal -->
|
|
||||||
<Transition name="modal">
|
|
||||||
<div
|
|
||||||
v-if="modelValue && structureId != null && structure"
|
|
||||||
class="fiche-modal-v2 fixed z-[1501] left-1/2 -translate-x-1/2 flex flex-col"
|
|
||||||
style="
|
|
||||||
width: min(780px, 94vw);
|
|
||||||
background: var(--nav-bg);
|
|
||||||
border-radius: 16px;
|
|
||||||
box-shadow: 0 16px 64px rgba(26,34,56,0.28);
|
|
||||||
overflow: hidden;
|
|
||||||
"
|
|
||||||
role="dialog"
|
|
||||||
aria-modal="true"
|
|
||||||
:aria-label="structure?.nom ?? 'Fiche structure'"
|
|
||||||
@keydown.esc="close"
|
|
||||||
>
|
|
||||||
<!-- Header modal -->
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-between px-5 py-3 shrink-0"
|
|
||||||
:style="`border-bottom: 3px solid ${familleColor}; background: var(--nav-surface);`"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<!-- Pastille famille -->
|
|
||||||
<div
|
|
||||||
class="w-3 h-3 rounded-full shrink-0"
|
|
||||||
:style="`background: ${familleColor};`"
|
|
||||||
/>
|
|
||||||
<span class="text-sm font-semibold" style="color: var(--nav-text-muted);">
|
|
||||||
{{ familleLabel }}
|
|
||||||
</span>
|
|
||||||
<!-- Badges -->
|
|
||||||
<div class="flex gap-1.5">
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.centre_ressources"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #2d8a6b22; color: #2d8a6b;"
|
|
||||||
>Centre ressources</span>
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.mouvement_manifeste"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #c44a2f22; color: #c44a2f;"
|
|
||||||
>Manifeste</span>
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.contre_pouvoir_spatial"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #1a3a6b22; color: #1a3a6b;"
|
|
||||||
>Contre-pouvoir</span>
|
|
||||||
<span
|
|
||||||
v-if="structure.badges.f6_recherche_politique"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs font-medium"
|
|
||||||
style="background: #6b3fa022; color: #6b3fa0;"
|
|
||||||
>Recherche pol.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a
|
|
||||||
v-if="structure.url"
|
|
||||||
:href="structure.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-opacity hover:opacity-70"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text);"
|
|
||||||
>
|
|
||||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
|
|
||||||
<polyline points="15 3 21 3 21 9"/>
|
|
||||||
<line x1="10" y1="14" x2="21" y2="3"/>
|
|
||||||
</svg>
|
|
||||||
Site web
|
|
||||||
</a>
|
|
||||||
<button
|
|
||||||
@click="close"
|
|
||||||
class="w-8 h-8 rounded-lg flex items-center justify-center transition-colors"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
aria-label="Fermer"
|
|
||||||
>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" aria-hidden="true">
|
|
||||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Contenu scrollable -->
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-5">
|
|
||||||
<!-- Nom + meta -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<h2 class="text-xl font-bold mb-1" style="color: var(--nav-text);">{{ structure.nom }}</h2>
|
|
||||||
<div class="flex flex-wrap gap-2 text-sm" style="color: var(--nav-text-muted);">
|
|
||||||
<span>{{ structure.type_principal }}</span>
|
|
||||||
<span>·</span>
|
|
||||||
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hashtags -->
|
|
||||||
<div v-if="structure.hashtags.length" class="flex flex-wrap gap-1.5 mb-4">
|
|
||||||
<span
|
|
||||||
v-for="tag in structure.hashtags"
|
|
||||||
:key="tag"
|
|
||||||
class="px-2 py-0.5 rounded-full text-xs"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>{{ tag }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Description courte -->
|
|
||||||
<p class="text-sm leading-relaxed mb-4" style="color: var(--nav-text);">{{ structure.description_courte }}</p>
|
|
||||||
|
|
||||||
<!-- Description longue (expandable) -->
|
|
||||||
<div v-if="structure.description_longue" class="mb-4">
|
|
||||||
<div
|
|
||||||
class="text-sm leading-relaxed"
|
|
||||||
style="color: var(--nav-text); white-space: pre-wrap;"
|
|
||||||
:style="showFullDesc ? '' : 'max-height: 120px; overflow: hidden;'"
|
|
||||||
>{{ structure.description_longue }}</div>
|
|
||||||
<button
|
|
||||||
@click="showFullDesc = !showFullDesc"
|
|
||||||
class="mt-2 text-xs underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pensées rattachées -->
|
|
||||||
<div v-if="structure.pensees && structure.pensees.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Pensées rattachées</h3>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<span
|
|
||||||
v-for="pensee in structure.pensees"
|
|
||||||
:key="pensee.id"
|
|
||||||
class="px-2 py-0.5 rounded text-xs"
|
|
||||||
:style="pensee.confiance === 'ia_suggested'
|
|
||||||
? 'background: var(--nav-bg-alt); color: var(--nav-text-muted); border: 1px dashed var(--nav-bg-alt);'
|
|
||||||
: 'background: var(--nav-bg-alt); color: var(--nav-text);'"
|
|
||||||
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
|
||||||
>
|
|
||||||
{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" class="ml-1 opacity-60">~</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Projets emblématiques -->
|
|
||||||
<div v-if="projetsStructure.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Projets emblématiques</h3>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<div
|
|
||||||
v-for="projet in projetsStructure.slice(0, 5)"
|
|
||||||
:key="projet.id"
|
|
||||||
class="rounded-lg p-3"
|
|
||||||
style="background: var(--nav-surface); border: 1px solid var(--nav-bg-alt);"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between gap-2">
|
|
||||||
<span class="font-medium text-sm" style="color: var(--nav-text);">{{ projet.nom }}</span>
|
|
||||||
<span v-if="projet.annee" class="text-xs shrink-0" style="color: var(--nav-text-muted);">{{ projet.annee }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="projet.lieu" class="text-xs mt-0.5" style="color: var(--nav-text-muted);">{{ projet.lieu }}</div>
|
|
||||||
<p class="text-xs mt-1 leading-relaxed" style="color: var(--nav-text-muted);">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
|
||||||
<a
|
|
||||||
v-if="projet.url"
|
|
||||||
:href="projet.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs mt-1 inline-block"
|
|
||||||
style="color: var(--nav-text-muted); text-decoration: underline;"
|
|
||||||
>En savoir plus →</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Structures voisines (graphe) -->
|
|
||||||
<div v-if="structuresVoisines.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Structures liées</h3>
|
|
||||||
<div class="flex flex-wrap gap-2">
|
|
||||||
<button
|
|
||||||
v-for="voisine in structuresVoisines.slice(0, 6)"
|
|
||||||
:key="voisine.id"
|
|
||||||
class="px-2 py-1 rounded text-xs transition-colors hover:opacity-70"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text); border: 1px solid transparent;"
|
|
||||||
@click="emit('update:structureId', voisine.id)"
|
|
||||||
>{{ voisine.nom }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Sources -->
|
|
||||||
<div v-if="structure.sources && structure.sources.length" class="mb-4">
|
|
||||||
<h3 class="text-xs font-bold uppercase tracking-wide mb-2" style="color: var(--nav-text-muted);">Sources</h3>
|
|
||||||
<div class="space-y-1">
|
|
||||||
<div v-for="(source, i) in structure.sources" :key="i" class="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
class="px-1.5 py-0.5 rounded text-xs shrink-0"
|
|
||||||
style="background: var(--nav-bg-alt); color: var(--nav-text-muted);"
|
|
||||||
>{{ source.type }}</span>
|
|
||||||
<a
|
|
||||||
v-if="source.url"
|
|
||||||
:href="source.url"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
class="text-xs underline truncate"
|
|
||||||
style="color: var(--nav-text);"
|
|
||||||
>{{ source.titre }}</a>
|
|
||||||
<span v-else class="text-xs" style="color: var(--nav-text);">{{ source.titre }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CTAs -->
|
|
||||||
<div class="flex gap-3 pt-2" style="border-top: 1px solid var(--nav-bg-alt);">
|
|
||||||
<a
|
|
||||||
href="/contribuer"
|
|
||||||
class="text-xs underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Signaler une erreur</a>
|
|
||||||
<a
|
|
||||||
href="/contribuer"
|
|
||||||
class="text-xs underline"
|
|
||||||
style="color: var(--nav-text-muted);"
|
|
||||||
>Réclamer cette fiche</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
|
||||||
|
|
||||||
const FAMILLE_COLORS: Record<number, string> = {
|
|
||||||
1: '#a85d3e',
|
|
||||||
2: '#c4a472',
|
|
||||||
3: '#d4a017',
|
|
||||||
4: '#5a7a4a',
|
|
||||||
5: '#3d6a8c',
|
|
||||||
}
|
|
||||||
|
|
||||||
const FAMILLE_LABELS: Record<number, string> = {
|
|
||||||
1: 'Réemploi & filières',
|
|
||||||
2: 'Frugalité & low-tech',
|
|
||||||
3: 'Architecture sociale',
|
|
||||||
4: 'Collectifs & AMO',
|
|
||||||
5: 'Urbanisme de transition',
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: boolean
|
|
||||||
structureId: string | null
|
|
||||||
data: ReseauxBifurcationData | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: boolean]
|
|
||||||
'update:structureId': [id: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const showFullDesc = ref(false)
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('update:modelValue', false)
|
|
||||||
showFullDesc.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fermeture Esc globale
|
|
||||||
onMounted(() => {
|
|
||||||
const handler = (e: KeyboardEvent) => {
|
|
||||||
if (e.key === 'Escape' && props.modelValue) close()
|
|
||||||
}
|
|
||||||
window.addEventListener('keydown', handler)
|
|
||||||
onUnmounted(() => window.removeEventListener('keydown', handler))
|
|
||||||
})
|
|
||||||
|
|
||||||
// Remettre showFullDesc a false a chaque changement de fiche
|
|
||||||
watch(() => props.structureId, () => {
|
|
||||||
showFullDesc.value = false
|
|
||||||
})
|
|
||||||
|
|
||||||
const structure = computed<StructureV2 | null>(() => {
|
|
||||||
if (!props.data || !props.structureId) return null
|
|
||||||
return props.data.structures.find(s => s.id === props.structureId) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const familleColor = computed(() =>
|
|
||||||
FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888'
|
|
||||||
)
|
|
||||||
|
|
||||||
const familleLabel = computed(() =>
|
|
||||||
FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? ''
|
|
||||||
)
|
|
||||||
|
|
||||||
const projetsStructure = computed<ProjetEmblematique[]>(() => {
|
|
||||||
if (!props.data || !props.structureId) return []
|
|
||||||
return props.data.projets?.filter(p => p.structure_parent === props.structureId) ?? []
|
|
||||||
})
|
|
||||||
|
|
||||||
const structuresVoisines = computed<StructureV2[]>(() => {
|
|
||||||
if (!props.data || !props.structureId) return []
|
|
||||||
const edges = props.data.graphe?.edges ?? []
|
|
||||||
const voisineIds = edges
|
|
||||||
.filter(e => e.source === props.structureId || e.target === props.structureId)
|
|
||||||
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
|
||||||
.map(e => e.source === props.structureId ? e.target : e.source)
|
|
||||||
.slice(0, 8)
|
|
||||||
|
|
||||||
return voisineIds
|
|
||||||
.map(id => props.data!.structures.find(s => s.id === id))
|
|
||||||
.filter(Boolean) as StructureV2[]
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* Backdrop */
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: opacity 0.2s ease; }
|
|
||||||
.backdrop-enter-from, .backdrop-leave-to { opacity: 0; }
|
|
||||||
|
|
||||||
/* Modal positionnement : centré desktop, descendu sous le header sur mobile */
|
|
||||||
.fiche-modal-v2 {
|
|
||||||
top: 50%;
|
|
||||||
transform: translate(-50%, -50%);
|
|
||||||
max-height: 90vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.fiche-modal-v2 {
|
|
||||||
top: 76px;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
max-height: calc(100dvh - 92px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Modal */
|
|
||||||
.modal-enter-active, .modal-leave-active {
|
|
||||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
|
||||||
}
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translate(-50%, -52%);
|
|
||||||
}
|
|
||||||
@media (max-width: 1023px) {
|
|
||||||
.modal-enter-from, .modal-leave-to {
|
|
||||||
transform: translate(-50%, calc(-2% + 76px));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.backdrop-enter-active, .backdrop-leave-active { transition: none; }
|
|
||||||
.modal-enter-active, .modal-leave-active { transition: none; }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<!--
|
||||||
|
FicheReseauContenu — B12-M4. Le corps de l'ancienne FicheModalV2 (retirée),
|
||||||
|
devenu un contenu : la vue fiches le pose dans son panneau de détail
|
||||||
|
(slot #detail de VueFiches). Badges, hashtags, descriptions, pensées,
|
||||||
|
projets, structures liées, sources.
|
||||||
|
|
||||||
|
Hashtag cliqué → `etiquette` (la vue fiches ajoute le filtre) ; structure
|
||||||
|
liée cliquée → `voisin` (la page l'ouvre dans le même panneau).
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<article v-if="structure" class="frc">
|
||||||
|
<div class="frc-famille" :style="`border-bottom-color: ${familleColor};`">
|
||||||
|
<span class="frc-famille__pastille" :style="`background: ${familleColor};`" aria-hidden="true" />
|
||||||
|
<span class="frc-famille__libelle">{{ familleLabel }}</span>
|
||||||
|
<span v-if="structure.badges.centre_ressources" class="frc-badge" style="background: #2d8a6b22; color: #1f6650;">Centre ressources</span>
|
||||||
|
<span v-if="structure.badges.mouvement_manifeste" class="frc-badge" style="background: #c44a2f22; color: #9a3520;">Manifeste</span>
|
||||||
|
<span v-if="structure.badges.contre_pouvoir_spatial" class="frc-badge" style="background: #1a3a6b22; color: #1a3a6b;">Contre-pouvoir</span>
|
||||||
|
<span v-if="structure.badges.f6_recherche_politique" class="frc-badge" style="background: #6b3fa022; color: #5a3388;">Recherche pol.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="frc-nom">{{ structure.nom }}</h2>
|
||||||
|
<p class="frc-meta">
|
||||||
|
<span>{{ structure.type_principal }}</span>
|
||||||
|
<span aria-hidden="true"> · </span>
|
||||||
|
<span>{{ structure.ville }}, {{ structure.pays }}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<a
|
||||||
|
v-if="structure.url"
|
||||||
|
:href="structure.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="frc-site"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
||||||
|
<polyline points="15 3 21 3 21 9" />
|
||||||
|
<line x1="10" y1="14" x2="21" y2="3" />
|
||||||
|
</svg>
|
||||||
|
Site web<span class="frc-sr"> (nouvel onglet)</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ChipGroup v-if="structure.hashtags.length" :aria-label="`Hashtags de ${structure.nom}`" class="frc-tags">
|
||||||
|
<Chip
|
||||||
|
v-for="tag in structure.hashtags"
|
||||||
|
:key="tag"
|
||||||
|
variant="fonction"
|
||||||
|
:label="tag"
|
||||||
|
:active="etiquettesActives?.includes(tag)"
|
||||||
|
:aria-label="`Filtrer par ${tag}`"
|
||||||
|
@toggle="emit('etiquette', tag)"
|
||||||
|
/>
|
||||||
|
</ChipGroup>
|
||||||
|
|
||||||
|
<p class="frc-texte">{{ structure.description_courte }}</p>
|
||||||
|
|
||||||
|
<div v-if="structure.description_longue" class="frc-bloc">
|
||||||
|
<div class="frc-texte frc-long" :class="{ 'frc-long--replie': !showFullDesc }">{{ structure.description_longue }}</div>
|
||||||
|
<button type="button" class="frc-lien" :aria-expanded="showFullDesc" @click="showFullDesc = !showFullDesc">
|
||||||
|
{{ showFullDesc ? 'Réduire' : 'Lire la suite…' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="structure.pensees && structure.pensees.length" class="frc-bloc">
|
||||||
|
<h3 class="frc-titre">Pensées rattachées</h3>
|
||||||
|
<div class="frc-liste-inline">
|
||||||
|
<span
|
||||||
|
v-for="pensee in structure.pensees"
|
||||||
|
:key="pensee.id"
|
||||||
|
class="frc-pensee"
|
||||||
|
:class="{ 'frc-pensee--ia': pensee.confiance === 'ia_suggested' }"
|
||||||
|
:title="pensee.confiance === 'ia_suggested' ? 'IA suggéré' : ''"
|
||||||
|
>{{ pensee.label }}<span v-if="pensee.confiance === 'ia_suggested'" aria-hidden="true"> ~</span><span v-if="pensee.confiance === 'ia_suggested'" class="frc-sr"> (suggéré par l'IA)</span></span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="projetsStructure.length" class="frc-bloc">
|
||||||
|
<h3 class="frc-titre">Projets emblématiques</h3>
|
||||||
|
<div class="frc-projets">
|
||||||
|
<div v-for="projet in projetsStructure.slice(0, 5)" :key="projet.id" class="frc-projet">
|
||||||
|
<div class="frc-projet__tete">
|
||||||
|
<span class="frc-projet__nom">{{ projet.nom }}</span>
|
||||||
|
<span v-if="projet.annee" class="frc-discret">{{ projet.annee }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="projet.lieu" class="frc-discret">{{ projet.lieu }}</div>
|
||||||
|
<p class="frc-projet__desc">{{ projet.description.slice(0, 120) }}{{ projet.description.length > 120 ? '…' : '' }}</p>
|
||||||
|
<a v-if="projet.url" :href="projet.url" target="_blank" rel="noopener noreferrer" class="frc-lien">En savoir plus<span class="frc-sr"> sur {{ projet.nom }} (nouvel onglet)</span> →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="structuresVoisines.length" class="frc-bloc">
|
||||||
|
<h3 class="frc-titre">Structures liées</h3>
|
||||||
|
<div class="frc-liste-inline">
|
||||||
|
<button
|
||||||
|
v-for="voisine in structuresVoisines.slice(0, 6)"
|
||||||
|
:key="voisine.id"
|
||||||
|
type="button"
|
||||||
|
class="frc-voisin"
|
||||||
|
@click="emit('voisin', voisine.id)"
|
||||||
|
>{{ voisine.nom }}</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="structure.sources && structure.sources.length" class="frc-bloc">
|
||||||
|
<h3 class="frc-titre">Sources</h3>
|
||||||
|
<ul class="frc-sources">
|
||||||
|
<li v-for="(source, i) in structure.sources" :key="i">
|
||||||
|
<span class="frc-source__type">{{ source.type }}</span>
|
||||||
|
<a v-if="source.url" :href="source.url" target="_blank" rel="noopener noreferrer" class="frc-lien">{{ source.titre }}</a>
|
||||||
|
<span v-else>{{ source.titre }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="frc-cta">
|
||||||
|
<NuxtLink to="/contribuer" class="frc-lien">Signaler une erreur</NuxtLink>
|
||||||
|
<NuxtLink to="/contribuer" class="frc-lien">Réclamer cette fiche</NuxtLink>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ReseauxBifurcationData, StructureV2, ProjetEmblematique } from '~/types/structure-v2'
|
||||||
|
|
||||||
|
const FAMILLE_COLORS: Record<number, string> = {
|
||||||
|
1: '#a85d3e',
|
||||||
|
2: '#c4a472',
|
||||||
|
3: '#d4a017',
|
||||||
|
4: '#5a7a4a',
|
||||||
|
5: '#3d6a8c',
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAMILLE_LABELS: Record<number, string> = {
|
||||||
|
1: 'Réemploi & filières',
|
||||||
|
2: 'Frugalité & low-tech',
|
||||||
|
3: 'Architecture sociale',
|
||||||
|
4: 'Collectifs & AMO',
|
||||||
|
5: 'Urbanisme de transition',
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
structureId: string
|
||||||
|
data: ReseauxBifurcationData | null
|
||||||
|
/** Hashtags actifs dans les filtres (avec leur « # ») : la chip s'allume. */
|
||||||
|
etiquettesActives?: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
etiquette: [hashtag: string]
|
||||||
|
voisin: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const showFullDesc = ref(false)
|
||||||
|
watch(() => props.structureId, () => { showFullDesc.value = false })
|
||||||
|
|
||||||
|
const structure = computed<StructureV2 | null>(() =>
|
||||||
|
props.data?.structures.find((s) => s.id === props.structureId) ?? null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const familleColor = computed(() => FAMILLE_COLORS[structure.value?.famille_principale ?? 1] ?? '#888')
|
||||||
|
const familleLabel = computed(() => FAMILLE_LABELS[structure.value?.famille_principale ?? 1] ?? '')
|
||||||
|
|
||||||
|
const projetsStructure = computed<ProjetEmblematique[]>(() =>
|
||||||
|
props.data?.projets?.filter((p) => p.structure_parent === props.structureId) ?? [],
|
||||||
|
)
|
||||||
|
|
||||||
|
const structuresVoisines = computed<StructureV2[]>(() => {
|
||||||
|
if (!props.data) return []
|
||||||
|
const edges = props.data.graphe?.edges ?? []
|
||||||
|
return edges
|
||||||
|
.filter((e) => e.source === props.structureId || e.target === props.structureId)
|
||||||
|
.sort((a, b) => (b.score ?? 0) - (a.score ?? 0))
|
||||||
|
.map((e) => (e.source === props.structureId ? e.target : e.source))
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((id) => props.data!.structures.find((s) => s.id === id))
|
||||||
|
.filter(Boolean) as StructureV2[]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.frc { color: var(--text); font-size: var(--fs-sm); }
|
||||||
|
.frc-sr {
|
||||||
|
position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
|
||||||
|
overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frc-famille {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2xs) var(--space-xs);
|
||||||
|
padding-bottom: var(--space-xs);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
border-bottom: 3px solid;
|
||||||
|
}
|
||||||
|
.frc-famille__pastille { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
|
||||||
|
.frc-famille__libelle { font-weight: var(--fw-semibold); color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||||
|
.frc-badge {
|
||||||
|
padding: 2px var(--space-xs);
|
||||||
|
border-radius: var(--radius-full);
|
||||||
|
font-size: var(--fs-xs, 0.75rem);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frc-nom { margin: 0 0 var(--space-2xs); font-size: 1.25rem; font-weight: var(--fw-bold, 700); line-height: 1.25; }
|
||||||
|
.frc-meta { margin: 0 0 var(--space-sm); color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||||
|
|
||||||
|
.frc-site {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2xs);
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 var(--space-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frc-tags { margin-bottom: var(--space-sm); }
|
||||||
|
|
||||||
|
.frc-texte { margin: 0 0 var(--space-sm); line-height: var(--lh-base, 1.55); }
|
||||||
|
.frc-long { white-space: pre-wrap; margin: 0; }
|
||||||
|
.frc-long--replie { max-height: 120px; overflow: hidden; }
|
||||||
|
|
||||||
|
.frc-bloc { margin-bottom: var(--space-md); }
|
||||||
|
.frc-titre {
|
||||||
|
margin: 0 0 var(--space-xs);
|
||||||
|
font-size: var(--fs-xs, 0.75rem);
|
||||||
|
font-weight: var(--fw-bold, 700);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.frc-liste-inline { display: flex; flex-wrap: wrap; gap: var(--space-2xs); }
|
||||||
|
.frc-pensee {
|
||||||
|
padding: 2px var(--space-xs);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
font-size: var(--fs-xs, 0.75rem);
|
||||||
|
}
|
||||||
|
.frc-pensee--ia { color: color-mix(in srgb, var(--text) 78%, transparent); border: 1px dashed var(--border); }
|
||||||
|
|
||||||
|
.frc-projets { display: flex; flex-direction: column; gap: var(--space-xs); }
|
||||||
|
.frc-projet { padding: var(--space-sm); border-radius: var(--radius-md); background: var(--bg); border: 1px solid var(--border); }
|
||||||
|
.frc-projet__tete { display: flex; justify-content: space-between; gap: var(--space-xs); }
|
||||||
|
.frc-projet__nom { font-weight: var(--fw-medium); }
|
||||||
|
.frc-projet__desc { margin: var(--space-2xs) 0; font-size: var(--fs-xs, 0.75rem); line-height: 1.5; color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||||
|
.frc-discret { font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); }
|
||||||
|
|
||||||
|
.frc-voisin {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-xs, 0.75rem);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frc-sources { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-2xs); }
|
||||||
|
.frc-sources li { display: flex; align-items: baseline; gap: var(--space-xs); min-width: 0; font-size: var(--fs-xs, 0.75rem); overflow-wrap: anywhere; }
|
||||||
|
.frc-source__type { flex-shrink: 0; padding: 1px var(--space-2xs); border-radius: var(--radius-sm); background: var(--bg-alt); }
|
||||||
|
|
||||||
|
.frc-lien {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-xs, 0.75rem);
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frc-cta { display: flex; gap: var(--space-sm); padding-top: var(--space-xs); margin: 0; border-top: 1px solid var(--border); }
|
||||||
|
|
||||||
|
.frc-site:focus-visible,
|
||||||
|
.frc-lien:focus-visible,
|
||||||
|
.frc-voisin:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -26,6 +26,8 @@ const props = defineProps<{
|
|||||||
selectedId?: number | null
|
selectedId?: number | null
|
||||||
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
|
/** Fiche survolée dans la liste (B11) : épingle safran, sans recentrer. */
|
||||||
survolId?: number | null
|
survolId?: number | null
|
||||||
|
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
|
||||||
|
centrage?: { id: number; n: number } | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -275,4 +277,20 @@ onUnmounted(() => {
|
|||||||
mapInstance = null
|
mapInstance = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
|
||||||
|
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
|
||||||
|
watch(
|
||||||
|
() => props.centrage?.n,
|
||||||
|
() => {
|
||||||
|
const c = props.centrage
|
||||||
|
if (!c) return
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mapInstance || !clusterGroup) return
|
||||||
|
mapInstance.invalidateSize({ pan: false })
|
||||||
|
const m = markers.get(c.id)
|
||||||
|
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
|
||||||
|
}, 260)
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ const props = defineProps<{
|
|||||||
selectedId?: string | null
|
selectedId?: string | null
|
||||||
/** Fiche survolée dans la liste (B11) : épingle cerclée de safran, sans recentrer. */
|
/** Fiche survolée dans la liste (B11) : épingle cerclée de safran, sans recentrer. */
|
||||||
survolId?: string | null
|
survolId?: string | null
|
||||||
|
/** « Voir sur la carte » (B12) : zoome jusqu'à l'épingle, sortie de son cluster ; `n` change à chaque demande. */
|
||||||
|
centrage?: { id: string; n: number } | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -272,4 +274,20 @@ onUnmounted(() => {
|
|||||||
mapInstance = null
|
mapInstance = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// « Voir sur la carte » (B12) : la carte vient peut-être de s'afficher (Fiches → Mixte) ;
|
||||||
|
// on attend sa taille réelle, puis on sort l'épingle de son cluster.
|
||||||
|
watch(
|
||||||
|
() => props.centrage?.n,
|
||||||
|
() => {
|
||||||
|
const c = props.centrage
|
||||||
|
if (!c) return
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!mapInstance || !clusterGroup) return
|
||||||
|
mapInstance.invalidateSize({ pan: false })
|
||||||
|
const m = markers.get(c.id)
|
||||||
|
if (m) clusterGroup.zoomToShowLayer(m, () => mapInstance?.panTo(m.getLatLng(), { animate: true }))
|
||||||
|
}, 260)
|
||||||
|
},
|
||||||
|
)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+329
-7
@@ -11,9 +11,9 @@
|
|||||||
Contrat complet : DESIGN-SYSTEM.md §Vue fiches.
|
Contrat complet : DESIGN-SYSTEM.md §Vue fiches.
|
||||||
-->
|
-->
|
||||||
<template>
|
<template>
|
||||||
<div class="vf" :class="`vf--${vf.vueAffichee}`">
|
<div class="vf" :class="[`vf--${vf.vueAffichee}`, { 'vf--detail': vf.ficheId != null }]">
|
||||||
<!-- ═══════════ Barre d'outils -->
|
<!-- ═══════════ Barre d'outils -->
|
||||||
<div class="vf-barre">
|
<div class="vf-barre" :inert="detailCouvrant || undefined">
|
||||||
<div class="vf-seg" role="group" aria-label="Affichage">
|
<div class="vf-seg" role="group" aria-label="Affichage">
|
||||||
<button
|
<button
|
||||||
v-for="e in etatsBoutons"
|
v-for="e in etatsBoutons"
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Téléphone, état Fiches : les pastilles actives sous la barre (les chips sont dans la feuille) -->
|
<!-- Téléphone, état Fiches : les pastilles actives sous la barre (les chips sont dans la feuille) -->
|
||||||
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--tel">
|
<div v-if="pastilles.length" class="vf-pastilles vf-pastilles--tel" :inert="detailCouvrant || undefined">
|
||||||
<Chip
|
<Chip
|
||||||
v-for="p in pastilles"
|
v-for="p in pastilles"
|
||||||
:key="p.cle"
|
:key="p.cle"
|
||||||
@@ -116,9 +116,11 @@
|
|||||||
<!-- ═══════════ Corps : liste | carte -->
|
<!-- ═══════════ Corps : liste | carte -->
|
||||||
<div class="vf-corps">
|
<div class="vf-corps">
|
||||||
<section
|
<section
|
||||||
|
ref="liste"
|
||||||
class="vf-liste"
|
class="vf-liste"
|
||||||
:aria-label="`Liste des fiches`"
|
:aria-label="`Liste des fiches`"
|
||||||
:aria-hidden="vf.vueAffichee === 'carte' ? 'true' : undefined"
|
:aria-hidden="vf.vueAffichee === 'carte' || detailCouvrant ? 'true' : undefined"
|
||||||
|
:inert="detailCouvrant || undefined"
|
||||||
>
|
>
|
||||||
<p v-if="intention" class="vf-intention">{{ intention }}</p>
|
<p v-if="intention" class="vf-intention">{{ intention }}</p>
|
||||||
|
|
||||||
@@ -154,11 +156,12 @@
|
|||||||
<FicheMiniCard
|
<FicheMiniCard
|
||||||
v-for="f in g.fiches"
|
v-for="f in g.fiches"
|
||||||
:key="`${f.kind}-${f.id}`"
|
:key="`${f.kind}-${f.id}`"
|
||||||
|
:data-fiche-id="String(f.id)"
|
||||||
:fiche="f"
|
:fiche="f"
|
||||||
etiquettes-cliquables
|
etiquettes-cliquables
|
||||||
:etiquettes-actives="libellesActifs"
|
:etiquettes-actives="libellesActifs"
|
||||||
:actif="String(f.id) === String(vf.survolId ?? '') || String(f.id) === String(selectedId ?? '')"
|
:actif="String(f.id) === String(vf.survolId ?? '') || String(f.id) === (vf.ficheId ?? String(selectedId ?? ''))"
|
||||||
@open="emit('select', f)"
|
@open="ouvrir(f)"
|
||||||
@etiquette="(l) => ajouterDepuisCarte(f, l)"
|
@etiquette="(l) => ajouterDepuisCarte(f, l)"
|
||||||
@mouseenter="survoler(f)"
|
@mouseenter="survoler(f)"
|
||||||
@mouseleave="survoler(null)"
|
@mouseleave="survoler(null)"
|
||||||
@@ -168,7 +171,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="vf-carte">
|
<div class="vf-carte" :inert="detailCouvrant || undefined">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="vf-poignee"
|
class="vf-poignee"
|
||||||
@@ -190,6 +193,71 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ═══════════ Détail (B12) : panneau à droite (≥ 768), plein écran au téléphone -->
|
||||||
|
<aside
|
||||||
|
v-if="vf.ficheId != null"
|
||||||
|
ref="detail"
|
||||||
|
class="vf-detail"
|
||||||
|
tabindex="-1"
|
||||||
|
:aria-label="vf.ficheOuverte ? `Fiche : ${vf.ficheOuverte.nom}` : 'Fiche'"
|
||||||
|
@keydown="clavierDetail"
|
||||||
|
@touchstart.passive="debutBalayage"
|
||||||
|
@touchend="finBalayage"
|
||||||
|
>
|
||||||
|
<div class="vf-detail__tete">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="vf-detail__fermer"
|
||||||
|
:aria-label="`Fermer la fiche et revenir aux ${vf.resultat.length} fiches`"
|
||||||
|
@click="vf.fermerFiche()"
|
||||||
|
>
|
||||||
|
<span class="vf-detail__fermer-tel" aria-hidden="true">‹ {{ vf.resultat.length }} {{ vf.resultat.length > 1 ? 'fiches' : 'fiche' }}</span>
|
||||||
|
<span class="vf-detail__fermer-ordi" aria-hidden="true">✕</span>
|
||||||
|
</button>
|
||||||
|
<span class="vf-detail__espace" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
v-if="vf.ficheOuverte"
|
||||||
|
type="button"
|
||||||
|
class="vf-detail__partager"
|
||||||
|
aria-label="Copier le lien de cette fiche"
|
||||||
|
@click="copierLien"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" /><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fiche à fiche : en tête sur ordinateur, en pied au téléphone (même élément, ordre CSS) -->
|
||||||
|
<nav v-if="vf.position" class="vf-detail__nav" aria-label="Fiche à fiche">
|
||||||
|
<button type="button" class="vf-detail__pas" :disabled="!vf.position.precedente" @click="vf.fichePrecedente()">
|
||||||
|
<span aria-hidden="true">‹ </span>Précédente
|
||||||
|
</button>
|
||||||
|
<span class="vf-detail__rang" aria-live="polite">
|
||||||
|
<span class="vf-sr">Fiche </span>{{ vf.position.rang }}<span class="vf-sr"> sur </span><span aria-hidden="true"> / </span>{{ vf.position.total }}
|
||||||
|
</span>
|
||||||
|
<button type="button" class="vf-detail__pas" :disabled="!vf.position.suivante" @click="vf.ficheSuivante()">
|
||||||
|
Suivante<span aria-hidden="true"> ›</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div ref="corpsDetail" class="vf-detail__corps">
|
||||||
|
<p v-if="vf.ficheOuverte && !vf.position" class="vf-detail__hors">
|
||||||
|
Hors de vos filtres : cette fiche n'est pas dans la liste affichée.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="vf.ficheOuverte">
|
||||||
|
<div class="vf-detail__actions">
|
||||||
|
<a v-if="lienPageFiche" :href="lienPageFiche" class="vf-lien">Ouvrir la page de la fiche</a>
|
||||||
|
<button v-if="peutCentrer" type="button" class="vf-lien" @click="vf.voirSurCarte(vf.ficheOuverte.id)">Voir sur la carte</button>
|
||||||
|
<button type="button" class="vf-lien" @click="copierLien">Copier le lien</button>
|
||||||
|
<span class="vf-detail__statut" role="status">{{ statutLien }}</span>
|
||||||
|
</div>
|
||||||
|
<slot name="detail" :fiche="vf.ficheOuverte" :filtrer="filtrerDepuisDetail" />
|
||||||
|
<p class="vf-detail__aide">← → pour passer d'une fiche à l'autre · Échap pour fermer</p>
|
||||||
|
</template>
|
||||||
|
<p v-else class="vf-etat-vide">{{ pending ? 'Chargement de la fiche…' : 'Fiche introuvable : elle a peut-être été retirée.' }}</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ═══════════ Feuille « Filtrer » (téléphone) -->
|
<!-- ═══════════ Feuille « Filtrer » (téléphone) -->
|
||||||
@@ -241,6 +309,8 @@ const props = defineProps<{
|
|||||||
libelleRecherche: string
|
libelleRecherche: string
|
||||||
pending?: boolean
|
pending?: boolean
|
||||||
selectedId?: string | number | null
|
selectedId?: string | number | null
|
||||||
|
/** Page propre de la fiche (`/fiche/:id` pour l'Entraide) ; null = pas de page, le lien partagé est l'URL avec `fiche=`. */
|
||||||
|
lienPage?: (f: MiniFiche) => string | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -336,6 +406,106 @@ function pieger(e: KeyboardEvent) {
|
|||||||
}
|
}
|
||||||
// La feuille n'existe que sous 768 px : elle se ferme si l'écran s'élargit.
|
// La feuille n'existe que sous 768 px : elle se ferme si l'écran s'élargit.
|
||||||
watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuverte.value = false })
|
watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuverte.value = false })
|
||||||
|
|
||||||
|
// ═══════════ Détail et fiche à fiche (B12) ═══════════
|
||||||
|
const liste = ref<HTMLElement | null>(null)
|
||||||
|
const detail = ref<HTMLElement | null>(null)
|
||||||
|
const corpsDetail = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
/** Téléphone : le détail couvre la vue, le reste sort du clavier et des lecteurs d'écran. */
|
||||||
|
const detailCouvrant = computed(() => props.vf.ficheId != null && props.vf.largeur === 'telephone')
|
||||||
|
|
||||||
|
const lienPageFiche = computed(() => (props.vf.ficheOuverte && props.lienPage ? props.lienPage(props.vf.ficheOuverte) : null))
|
||||||
|
/** « Voir sur la carte » : la fiche a une épingle, et l'épingle est sur la carte (la carte ne montre que le résultat). */
|
||||||
|
const peutCentrer = computed(() => {
|
||||||
|
const f = props.vf.ficheOuverte
|
||||||
|
return !!f && f.geoloc && props.vf.cfg.etats.includes('carte') && props.vf.idsResultat.has(String(f.id))
|
||||||
|
})
|
||||||
|
|
||||||
|
function ouvrir(f: MiniFiche) {
|
||||||
|
props.vf.ouvrirFiche(f.id)
|
||||||
|
emit('select', f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Étiquette cliquée dans le détail : filtre, et au téléphone referme la fiche (on veut voir la liste filtrée). */
|
||||||
|
function filtrerDepuisDetail(dim: string, valeur: string) {
|
||||||
|
props.vf.filtrerDepuisFiche(dim, valeur, props.vf.largeur === 'telephone')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ouverture : on retient la position de défilement de la liste et la carte-fiche d'origine.
|
||||||
|
let defilementListe = 0
|
||||||
|
let origineId: string | null = null
|
||||||
|
watch(() => props.vf.ficheId, (id, avant) => {
|
||||||
|
if (!import.meta.client) return
|
||||||
|
if (id != null && avant == null) {
|
||||||
|
defilementListe = liste.value?.scrollTop ?? 0
|
||||||
|
origineId = id
|
||||||
|
nextTick(() => detail.value?.focus({ preventScroll: true }))
|
||||||
|
} else if (id != null && avant != null) {
|
||||||
|
// Fiche suivante : le corps repart en haut ; si le bouton pressé est devenu inactif (bout de liste), le focus reste dans le détail.
|
||||||
|
nextTick(() => {
|
||||||
|
corpsDetail.value?.scrollTo({ top: 0 })
|
||||||
|
if (!detail.value?.contains(document.activeElement)) detail.value?.focus({ preventScroll: true })
|
||||||
|
})
|
||||||
|
} else if (id == null && avant != null) {
|
||||||
|
// Fermeture : même position de défilement, focus sur la carte-fiche de la dernière fiche lue (sinon celle d'origine).
|
||||||
|
nextTick(() => {
|
||||||
|
if (liste.value) liste.value.scrollTop = defilementListe
|
||||||
|
const cible = [avant, origineId].map((x) => x && liste.value?.querySelector<HTMLElement>(`[data-fiche-id="${CSS.escape(x)}"] .fiche-mini-card__ouvrir`)).find(Boolean)
|
||||||
|
if (cible) { cible.focus({ preventScroll: true }); cible.scrollIntoView({ block: 'nearest' }) }
|
||||||
|
origineId = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function clavierDetail(e: KeyboardEvent) {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
const champ = !!t.closest('input, textarea, select, [contenteditable="true"]')
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
e.preventDefault()
|
||||||
|
// Échap dans un champ (commentaire, signalement) le quitte d'abord : un brouillon ne se perd pas d'une touche
|
||||||
|
if (champ) { detail.value?.focus({ preventScroll: true }); return }
|
||||||
|
props.vf.fermerFiche()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return
|
||||||
|
if (e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
|
if (champ) return
|
||||||
|
e.preventDefault()
|
||||||
|
if (e.key === 'ArrowLeft') props.vf.fichePrecedente()
|
||||||
|
else props.vf.ficheSuivante()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Balayage horizontal, au téléphone, dans le détail seulement (la carte garde le doigt).
|
||||||
|
let toucheX = 0
|
||||||
|
let toucheY = 0
|
||||||
|
function debutBalayage(e: TouchEvent) {
|
||||||
|
toucheX = e.changedTouches[0].clientX
|
||||||
|
toucheY = e.changedTouches[0].clientY
|
||||||
|
}
|
||||||
|
function finBalayage(e: TouchEvent) {
|
||||||
|
if (props.vf.largeur !== 'telephone') return
|
||||||
|
const dx = e.changedTouches[0].clientX - toucheX
|
||||||
|
const dy = e.changedTouches[0].clientY - toucheY
|
||||||
|
if (Math.abs(dx) < 60 || Math.abs(dx) < 1.5 * Math.abs(dy)) return
|
||||||
|
if (dx < 0) props.vf.ficheSuivante()
|
||||||
|
else props.vf.fichePrecedente()
|
||||||
|
}
|
||||||
|
|
||||||
|
// « Copier le lien » : l'URL courante dit tout (filtres, tri, vue, fiche).
|
||||||
|
const statutLien = ref('')
|
||||||
|
let minuteurLien: ReturnType<typeof setTimeout> | null = null
|
||||||
|
async function copierLien() {
|
||||||
|
const url = window.location.href
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url)
|
||||||
|
statutLien.value = 'Lien copié.'
|
||||||
|
} catch {
|
||||||
|
statutLien.value = `Copie impossible ici : ${url}`
|
||||||
|
}
|
||||||
|
if (minuteurLien) clearTimeout(minuteurLien)
|
||||||
|
minuteurLien = setTimeout(() => { statutLien.value = '' }, 4000)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -811,4 +981,156 @@ watch(() => props.vf.largeur, (l) => { if (l && l !== 'telephone') feuilleOuvert
|
|||||||
outline: 2px solid var(--focus-ring);
|
outline: 2px solid var(--focus-ring);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══════════ Détail (B12) : panneau de 380 px à droite, plein écran au téléphone */
|
||||||
|
.vf { position: relative; }
|
||||||
|
|
||||||
|
.vf-detail {
|
||||||
|
grid-row: 1;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.vf-detail:focus { outline: none; }
|
||||||
|
.vf-detail:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: -2px; }
|
||||||
|
|
||||||
|
.vf-detail__tete {
|
||||||
|
order: 0;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.vf-detail__espace { flex: 1 1 0; }
|
||||||
|
.vf-detail__fermer,
|
||||||
|
.vf-detail__partager {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-semibold);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.vf-detail__fermer { order: 2; }
|
||||||
|
.vf-detail__fermer-tel { display: none; }
|
||||||
|
.vf-detail__fermer-ordi { font-size: 1.05rem; }
|
||||||
|
.vf-detail__partager { order: 1; }
|
||||||
|
|
||||||
|
.vf-detail__nav {
|
||||||
|
order: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
padding: var(--space-2xs) var(--space-sm);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.vf-detail__pas {
|
||||||
|
min-height: 40px;
|
||||||
|
padding: 0 var(--space-xs);
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
font-weight: var(--fw-medium);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.vf-detail__pas:disabled { color: color-mix(in srgb, var(--text) 45%, transparent); cursor: default; }
|
||||||
|
.vf-detail__rang { font-size: var(--fs-sm); font-weight: var(--fw-semibold); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.vf-detail__corps {
|
||||||
|
order: 2;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: var(--space-sm) var(--space-md) var(--space-lg);
|
||||||
|
}
|
||||||
|
.vf-detail__hors {
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-alt);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
.vf-detail__actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2xs) var(--space-sm);
|
||||||
|
margin: 0 0 var(--space-sm);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
}
|
||||||
|
.vf-detail__actions .vf-lien { min-height: 32px; display: inline-flex; align-items: center; }
|
||||||
|
.vf-detail__statut { flex-basis: 100%; font-size: var(--fs-xs, 0.75rem); color: color-mix(in srgb, var(--text) 78%, transparent); overflow-wrap: anywhere; }
|
||||||
|
.vf-detail__statut:empty { display: none; }
|
||||||
|
.vf-detail__aide {
|
||||||
|
margin: var(--space-md) 0 0;
|
||||||
|
font-size: var(--fs-xs, 0.75rem);
|
||||||
|
color: color-mix(in srgb, var(--text) 78%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vf-detail__fermer:focus-visible,
|
||||||
|
.vf-detail__partager:focus-visible,
|
||||||
|
.vf-detail__pas:focus-visible,
|
||||||
|
.vf-detail__actions .vf-lien:focus-visible {
|
||||||
|
outline: 2px solid var(--focus-ring);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tablette : une colonne de liste ou de carte, le détail à côté */
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.vf--detail .vf-corps { grid-template-columns: minmax(0, 1fr) 340px; }
|
||||||
|
.vf-detail { grid-column: 2; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ordinateur : troisième colonne de 380 px, quel que soit l'état ; la grille passe à 2 colonnes */
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.vf-detail { grid-column: 3; }
|
||||||
|
.vf--detail .vf-corps { grid-template-columns: var(--vf-liste) minmax(0, 1fr) 380px; }
|
||||||
|
.vf--detail.vf--fiches .vf-corps { grid-template-columns: minmax(0, 1fr) 0 380px; }
|
||||||
|
.vf--detail.vf--carte .vf-corps { grid-template-columns: 0 minmax(0, 1fr) 380px; }
|
||||||
|
}
|
||||||
|
@media (min-width: 1280px) {
|
||||||
|
.vf--detail.vf--fiches .vf-grille { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Téléphone : plein écran par-dessus la vue (barre comprise), fiche à fiche en pied, à portée de pouce */
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
/* le détail se place par rapport à .vf (barre comprise), pas au corps */
|
||||||
|
.vf--detail .vf-corps { position: static; }
|
||||||
|
.vf-detail {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 1100;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
.vf-detail__fermer { order: 0; padding: 0 var(--space-xs) 0 0; }
|
||||||
|
.vf-detail__fermer-tel { display: inline; }
|
||||||
|
.vf-detail__fermer-ordi { display: none; }
|
||||||
|
.vf-detail__partager { order: 2; }
|
||||||
|
.vf-detail__nav {
|
||||||
|
order: 3;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.vf-detail__aide { display: none; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Mode de profondeur courant d'un chatbot (M3). État partagé par clé via useState :
|
||||||
|
* desktop et mobile d'un même chatbot (clés 'accueil', 'agences'…) restent synchronisés.
|
||||||
|
* Volontairement non persisté : le mode est un choix par question, 'rapide' à chaque visite.
|
||||||
|
*/
|
||||||
|
export function useChatProfondeur(key: string | (() => string) = 'defaut') {
|
||||||
|
const k = typeof key === 'function' ? key : () => key
|
||||||
|
const store = useState<Record<string, ChatProfondeur>>('aep-chat-profondeur', () => ({}))
|
||||||
|
return computed<ChatProfondeur>({
|
||||||
|
get: () => store.value[k()] ?? CHAT_PROFONDEUR_DEFAUT,
|
||||||
|
set: (v) => { store.value = { ...store.value, [k()]: normaliserProfondeur(v) } },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistance de l'historique d'un chatbot dans sessionStorage.
|
||||||
|
*
|
||||||
|
* - Clé de stockage : `aep-chat-<pageKey>`, valeur `{ v: 1, messages: [...] }`
|
||||||
|
* (une valeur d'une autre version est ignorée).
|
||||||
|
* - SSR-safe : aucun accès au stockage hors de onMounted. Côté serveur et à
|
||||||
|
* l'hydratation l'historique est vide, il est restauré juste après le mount.
|
||||||
|
* - Partagé : deux composants (desktop + mobile) qui déclarent la même clé
|
||||||
|
* lisent et écrivent le même tableau (useState), donc un redimensionnement
|
||||||
|
* ne perd rien et un composant n'écrase pas l'autre.
|
||||||
|
* - Tolérant : toute lecture/écriture est dans un try/catch (navigation
|
||||||
|
* privée, stockage bloqué, quota). En cas d'échec le chatbot fonctionne
|
||||||
|
* comme avant, sans persistance.
|
||||||
|
* - Les messages marqués `transient: true` (erreurs) restent affichés mais ne
|
||||||
|
* sont jamais écrits. Chargement et erreurs vivent hors de l'historique.
|
||||||
|
* - La forme des messages est libre (fiches, auteur, mode...) : tout champ
|
||||||
|
* sérialisable est conservé.
|
||||||
|
*/
|
||||||
|
export interface ChatSessionMessage {
|
||||||
|
role: string
|
||||||
|
content: string
|
||||||
|
transient?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_PREFIX = 'aep-chat-'
|
||||||
|
const SCHEMA_VERSION = 1
|
||||||
|
const DEFAULT_MAX = 40
|
||||||
|
|
||||||
|
export function useChatSession<T extends ChatSessionMessage>(
|
||||||
|
pageKey: MaybeRefOrGetter<string>,
|
||||||
|
options: { max?: number } = {},
|
||||||
|
) {
|
||||||
|
const max = options.max ?? DEFAULT_MAX
|
||||||
|
const store = useState<Record<string, T[]>>('aep-chat-store', () => ({}))
|
||||||
|
const restored = useState<Record<string, boolean>>('aep-chat-restored', () => ({}))
|
||||||
|
const key = computed(() => toValue(pageKey))
|
||||||
|
const mounted = ref(false)
|
||||||
|
|
||||||
|
function ensure(k: string) {
|
||||||
|
if (!store.value[k]) store.value[k] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = computed<T[]>({
|
||||||
|
get: () => store.value[key.value] ?? [],
|
||||||
|
set: (list) => { store.value[key.value] = list },
|
||||||
|
})
|
||||||
|
const hasHistory = computed(() => messages.value.length > 0)
|
||||||
|
|
||||||
|
function isValid(m: any): m is T {
|
||||||
|
return !!m && typeof m === 'object' && typeof m.role === 'string' && typeof m.content === 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function restore() {
|
||||||
|
const k = key.value
|
||||||
|
if (restored.value[k]) return
|
||||||
|
restored.value[k] = true
|
||||||
|
ensure(k)
|
||||||
|
try {
|
||||||
|
const raw = sessionStorage.getItem(STORAGE_PREFIX + k)
|
||||||
|
if (!raw) return
|
||||||
|
const data = JSON.parse(raw)
|
||||||
|
if (!data || data.v !== SCHEMA_VERSION || !Array.isArray(data.messages)) return
|
||||||
|
const valid = (data.messages as unknown[]).filter(isValid).slice(-max)
|
||||||
|
if (valid.length && store.value[k].length === 0) store.value[k] = valid
|
||||||
|
} catch {
|
||||||
|
/* stockage indisponible ou valeur corrompue : on repart de zéro */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
if (!mounted.value) return
|
||||||
|
try {
|
||||||
|
const list = messages.value.filter(m => !m.transient).slice(-max)
|
||||||
|
const sk = STORAGE_PREFIX + key.value
|
||||||
|
if (list.length === 0) sessionStorage.removeItem(sk)
|
||||||
|
else sessionStorage.setItem(sk, JSON.stringify({ v: SCHEMA_VERSION, messages: list }))
|
||||||
|
} catch {
|
||||||
|
/* quota, navigation privée : pas de persistance, pas d'erreur */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Efface l'historique affiché et la clé de stockage. */
|
||||||
|
function clear() {
|
||||||
|
store.value[key.value] = []
|
||||||
|
try { sessionStorage.removeItem(STORAGE_PREFIX + key.value) } catch { /* ignoré */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(key, (k) => {
|
||||||
|
ensure(k)
|
||||||
|
if (mounted.value) restore()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(messages, save, { deep: true })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
restore()
|
||||||
|
mounted.value = true
|
||||||
|
})
|
||||||
|
|
||||||
|
return { messages, hasHistory, clear }
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import type { MiniFiche } from '~/composables/useFicheAdapter'
|
|||||||
import {
|
import {
|
||||||
lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes,
|
lireQuery, ecrireQuery, filtresValides, filtrerFiches, compterEtiquettes,
|
||||||
triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs,
|
triEffectif, trierFiches, regrouper, ordreFiches, nbFiltresActifs,
|
||||||
|
lireFiche, queryAvecFiche, positionFiche, modeOuverture, modeFermeture, empreinteListe,
|
||||||
type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches,
|
type ConfigVueFiches, type EtatFiches, type EtatVue, type TriFiches,
|
||||||
} from '~/utils/vueFiches'
|
} from '~/utils/vueFiches'
|
||||||
|
|
||||||
@@ -50,11 +51,29 @@ export function useVueFiches(
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// ── Écriture ──
|
// ── Écriture ──
|
||||||
|
// La navigation est asynchrone : une query écrite n'est dans `route.query`
|
||||||
|
// qu'au tick suivant. Deux écritures du même tick (« Voir sur la carte » :
|
||||||
|
// Mixte, puis la sous-vue de la page) partent donc de la dernière query
|
||||||
|
// écrite, sinon la seconde efface la première.
|
||||||
|
let enAttente: Record<string, string> | null = null
|
||||||
|
watch(() => route.query, () => { enAttente = null })
|
||||||
|
const queryBase = () => enAttente ?? route.query
|
||||||
|
function naviguer(query: Record<string, string>, mode: 'push' | 'replace' = 'replace') {
|
||||||
|
enAttente = query
|
||||||
|
if (mode === 'push') router.push({ query })
|
||||||
|
else router.replace({ query })
|
||||||
|
}
|
||||||
|
function etatDepuis(base: Record<string, unknown>): EtatFiches {
|
||||||
|
const b = lireQuery(base, cfg.value)
|
||||||
|
return { ...b, q: q.value, filtres: filtresValides(b.filtres, cfg.value) }
|
||||||
|
}
|
||||||
|
|
||||||
function ecrire(partiel: Partial<EtatFiches>) {
|
function ecrire(partiel: Partial<EtatFiches>) {
|
||||||
const nouvel = { ...etat.value, ...partiel }
|
const base = queryBase()
|
||||||
|
const nouvel = { ...etatDepuis(base), ...partiel }
|
||||||
qEcrit = nouvel.q
|
qEcrit = nouvel.q
|
||||||
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null }
|
if (minuteurQ && partiel.q === undefined) { clearTimeout(minuteurQ); minuteurQ = null }
|
||||||
router.replace({ query: ecrireQuery(nouvel, cfg.value, route.query) })
|
naviguer(ecrireQuery(nouvel, cfg.value, base))
|
||||||
}
|
}
|
||||||
|
|
||||||
function setQ(v: string) {
|
function setQ(v: string) {
|
||||||
@@ -113,7 +132,8 @@ export function useVueFiches(
|
|||||||
const vueAffichee = computed<EtatVue | 'auto'>(() => {
|
const vueAffichee = computed<EtatVue | 'auto'>(() => {
|
||||||
const etats = cfg.value.etats
|
const etats = cfg.value.etats
|
||||||
const v = etat.value.vue
|
const v = etat.value.vue
|
||||||
if (largeur.value === null) return v && v !== 'mixte' ? v : 'auto'
|
// `auto` rend Mixte à partir de 1024 px : seulement pour une page qui a cet état
|
||||||
|
if (largeur.value === null) return v && v !== 'mixte' ? v : etats.includes('mixte') ? 'auto' : 'fiches'
|
||||||
const ordinateur = largeur.value === 'ordinateur'
|
const ordinateur = largeur.value === 'ordinateur'
|
||||||
if (v === 'mixte' || v === null) {
|
if (v === 'mixte' || v === null) {
|
||||||
if (!ordinateur || !etats.includes('mixte')) return 'fiches'
|
if (!ordinateur || !etats.includes('mixte')) return 'fiches'
|
||||||
@@ -154,11 +174,78 @@ export function useVueFiches(
|
|||||||
/** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */
|
/** Fiche survolée (liste ↔ épingle) ; la carte de la page s'en sert, et peut l'écrire. */
|
||||||
const survolId = ref<string | number | null>(null)
|
const survolId = ref<string | number | null>(null)
|
||||||
|
|
||||||
|
// ── Fiche ouverte (B12) : `fiche=` dans l'URL, lu aussi au rendu serveur ──
|
||||||
|
const ficheId = computed(() => lireFiche(route.query))
|
||||||
|
/** La fiche ouverte, cherchée dans TOUTES les fiches (elle peut être hors filtres). null = pas encore chargée ou inconnue. */
|
||||||
|
const ficheOuverte = computed<MiniFiche | null>(() => {
|
||||||
|
const id = ficheId.value
|
||||||
|
return id == null ? null : toValue(fiches).find((f) => String(f.id) === id) ?? null
|
||||||
|
})
|
||||||
|
/** Rang dans l'ordre courant ; null = hors de vos filtres (ou pas de fiche). */
|
||||||
|
const position = computed(() => positionFiche(ordre.value, ficheId.value))
|
||||||
|
/**
|
||||||
|
* Empreinte de la liste au moment où CETTE page a ajouté l'entrée
|
||||||
|
* d'historique de la fiche ; null = fiche venue d'un lien partagé.
|
||||||
|
*/
|
||||||
|
let listeALOuverture: string | null = null
|
||||||
|
|
||||||
|
function ouvrirFiche(id: string | number) {
|
||||||
|
if (String(id) === lireFiche(queryBase())) return
|
||||||
|
const base = queryBase()
|
||||||
|
const mode = modeOuverture(lireFiche(base))
|
||||||
|
if (mode === 'push') listeALOuverture = empreinteListe(base)
|
||||||
|
naviguer(queryAvecFiche(base, id), mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fermerFiche() {
|
||||||
|
if (lireFiche(queryBase()) == null) return
|
||||||
|
const mode = modeFermeture(listeALOuverture != null, listeALOuverture === empreinteListe(queryBase()))
|
||||||
|
listeALOuverture = null
|
||||||
|
if (mode === 'back') { enAttente = null; router.back() }
|
||||||
|
else naviguer(queryAvecFiche(queryBase(), null))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Étiquette cliquée dans le détail : ajoute le filtre ; `fermer` (téléphone) referme la fiche dans le même geste. */
|
||||||
|
function filtrerDepuisFiche(dim: string, valeur: string, fermer: boolean) {
|
||||||
|
const base = queryBase()
|
||||||
|
const e = etatDepuis(base)
|
||||||
|
const vals = e.filtres[dim] ?? []
|
||||||
|
const filtres = vals.includes(valeur) ? e.filtres : { ...e.filtres, [dim]: [...vals, valeur] }
|
||||||
|
const query = ecrireQuery({ ...e, filtres }, cfg.value, base)
|
||||||
|
if (fermer) listeALOuverture = null
|
||||||
|
naviguer(fermer ? queryAvecFiche(query, null) : query)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retour arrière du navigateur (ou lien interne) : l'URL a perdu `fiche=`.
|
||||||
|
watch(ficheId, (id) => { if (id == null) listeALOuverture = null })
|
||||||
|
|
||||||
|
const fichePrecedente = () => { if (position.value?.precedente) ouvrirFiche(position.value.precedente.id) }
|
||||||
|
const ficheSuivante = () => { if (position.value?.suivante) ouvrirFiche(position.value.suivante.id) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* « Voir sur la carte » : ordinateur → Mixte, la fiche reste ouverte ;
|
||||||
|
* téléphone et tablette (pas de Mixte) → Carte, la fiche se ferme (elle
|
||||||
|
* couvrirait la carte). La page lit `centrage` et centre son épingle.
|
||||||
|
*/
|
||||||
|
const centrage = ref<{ id: string; n: number } | null>(null)
|
||||||
|
function voirSurCarte(id: string | number) {
|
||||||
|
centrage.value = { id: String(id), n: (centrage.value?.n ?? 0) + 1 }
|
||||||
|
if (largeur.value === 'ordinateur' && cfg.value.etats.includes('mixte')) {
|
||||||
|
setVue('mixte')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listeALOuverture = null
|
||||||
|
const base = queryBase()
|
||||||
|
naviguer(queryAvecFiche(ecrireQuery({ ...etatDepuis(base), vue: 'carte' }, cfg.value, base), null))
|
||||||
|
}
|
||||||
|
|
||||||
return reactive({
|
return reactive({
|
||||||
cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs,
|
cfg, etat, q, largeur, vueAffichee, tri, resultat, groupes, ordre, compteurs,
|
||||||
idsResultat, nbSansAdresse, total, nbActifs, survolId,
|
idsResultat, nbSansAdresse, total, nbActifs, survolId,
|
||||||
|
ficheId, ficheOuverte, position, centrage,
|
||||||
setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue,
|
setVue, setQ, toggle, ajouterFiltre, effacerTout, setTri, setGroupe, setSousVue,
|
||||||
setSansAdresse, voirSansAdresse,
|
setSansAdresse, voirSansAdresse,
|
||||||
|
ouvrirFiche, fermerFiche, fichePrecedente, ficheSuivante, voirSurCarte, filtrerDepuisFiche,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+36
-18
@@ -11,7 +11,6 @@
|
|||||||
libelle-recherche="Rechercher une structure…"
|
libelle-recherche="Rechercher une structure…"
|
||||||
:pending="pending"
|
:pending="pending"
|
||||||
:selected-id="selectedId"
|
:selected-id="selectedId"
|
||||||
@select="(f) => onSelectStructure(String(f.id))"
|
|
||||||
>
|
>
|
||||||
<template #carte>
|
<template #carte>
|
||||||
<!-- Métropole -->
|
<!-- Métropole -->
|
||||||
@@ -21,6 +20,7 @@
|
|||||||
:structures="metropoleStructures"
|
:structures="metropoleStructures"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
:survolId="vf.survolId == null ? null : String(vf.survolId)"
|
:survolId="vf.survolId == null ? null : String(vf.survolId)"
|
||||||
|
:centrage="centrageCarte"
|
||||||
@select-structure="onSelectStructure"
|
@select-structure="onSelectStructure"
|
||||||
@survol="(id) => (vf.survolId = id)"
|
@survol="(id) => (vf.survolId = id)"
|
||||||
/>
|
/>
|
||||||
@@ -58,20 +58,34 @@
|
|||||||
<!-- Chatbot sous la carte (ordinateur) -->
|
<!-- Chatbot sous la carte (ordinateur) -->
|
||||||
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
<div v-if="vf.etat.sousVue !== 'outremer'" class="hidden lg:block shrink-0">
|
||||||
<ChatbotPlaceholder
|
<ChatbotPlaceholder
|
||||||
|
endpoint="/api/chatbot-reseaux"
|
||||||
|
chat-key="agences"
|
||||||
|
titre="Assistant Réseaux AEP"
|
||||||
|
placeholder-replie="Pose une question sur les structures et agences du réseau…"
|
||||||
|
placeholder-saisie="Décris ta situation…"
|
||||||
|
intro="Je connais les 120 réseaux, collectifs et agences cartographiés dans AEP — ceux qui portent une vision écologique et politique de l'architecture. Décris ta situation : tu cherches un collectif, une agence inspirante, un partenaire sur un projet ?"
|
||||||
|
exemple="Exemple : « Quelles agences travaillent sur le réemploi ? »"
|
||||||
|
fiches-titre="Structures recommandées :"
|
||||||
|
fiche-lien-modele="/agences#{id}"
|
||||||
|
rendre-markdown
|
||||||
|
message-limite="Limite de 20 questions par jour atteinte."
|
||||||
@highlightOrgs="() => {}"
|
@highlightOrgs="() => {}"
|
||||||
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
|
@applyHashtag="(tag) => vf.ajouterFiltre('tags', sansDiese(tag))"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VueFiches>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ MODAL FICHE V2 -->
|
<!-- Détail (B12) : le corps de l'ancienne FicheModalV2 ; une structure liée s'ouvre dans le même panneau -->
|
||||||
<FicheModalV2
|
<template #detail="{ fiche, filtrer }">
|
||||||
v-model="ficheModalOpen"
|
<FicheReseauContenu
|
||||||
:structureId="ficheModalId"
|
:structure-id="String(fiche.id)"
|
||||||
:data="bifurcationData"
|
:data="bifurcationData"
|
||||||
@update:structureId="ficheModalId = $event"
|
:etiquettes-actives="(vf.etat.filtres.tags ?? []).map((t) => `#${t}`)"
|
||||||
/>
|
@etiquette="(tag) => filtrer('tags', sansDiese(tag))"
|
||||||
|
@voisin="(id) => vf.ouvrirFiche(id)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</VueFiches>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
<button
|
<button
|
||||||
@@ -221,18 +235,21 @@ const vf = useVueFiches(fichesMini, config)
|
|||||||
// La carte ne montre que le résultat courant (recherche + filtres)
|
// La carte ne montre que le résultat courant (recherche + filtres)
|
||||||
const metropoleStructures = computed<StructureV2[]>(() => structures.value.filter((s) => vf.idsResultat.has(s.id)))
|
const metropoleStructures = computed<StructureV2[]>(() => structures.value.filter((s) => vf.idsResultat.has(s.id)))
|
||||||
|
|
||||||
// ── Sélection : FicheModalV2 à toutes les largeurs (comportement d'avant B11) ──
|
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
|
||||||
const selectedId = ref<string | null>(null)
|
const selectedId = computed<string | null>(() => vf.ficheId)
|
||||||
const ficheModalOpen = ref(false)
|
|
||||||
const ficheModalId = ref<string | null>(null)
|
|
||||||
|
|
||||||
function onSelectStructure(id: string) {
|
function onSelectStructure(id: string) {
|
||||||
const ordinateur = typeof window !== 'undefined' && window.innerWidth >= 1024
|
vf.ouvrirFiche(id)
|
||||||
selectedId.value = ordinateur && selectedId.value === id ? null : id
|
|
||||||
ficheModalId.value = id
|
|
||||||
ficheModalOpen.value = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// « Voir sur la carte » : l'épingle est sur la carte Métropole (pas dans le graphe)
|
||||||
|
const centrageCarte = ref<{ id: string; n: number } | null>(null)
|
||||||
|
watch(() => vf.centrage, (c) => {
|
||||||
|
if (!c) return
|
||||||
|
if (vf.etat.sousVue !== 'metropole') vf.setSousVue('metropole')
|
||||||
|
centrageCarte.value = { id: c.id, n: c.n }
|
||||||
|
})
|
||||||
|
|
||||||
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
|
// ── Boutons flottants (téléphone) : au-dessus du pied « Voir les N fiches » en état Carte ──
|
||||||
const chatbotOpen = ref(false)
|
const chatbotOpen = ref(false)
|
||||||
const missionOpen = ref(false)
|
const missionOpen = ref(false)
|
||||||
@@ -244,7 +261,8 @@ onMounted(() => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({ title: "AEP - Réseaux de bifurcation architecturale" })
|
// Une fiche ouverte donne son nom à l'onglet (les données arrivent après le rendu serveur ici)
|
||||||
|
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP - Réseaux de bifurcation architecturale')) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
+49
-30
@@ -17,7 +17,7 @@
|
|||||||
libelle-recherche="Rechercher une structure…"
|
libelle-recherche="Rechercher une structure…"
|
||||||
:pending="pending"
|
:pending="pending"
|
||||||
:selected-id="selectedId"
|
:selected-id="selectedId"
|
||||||
@select="(f) => onSelectOrg(Number(f.id))"
|
:lien-page="(f) => `/fiche/${f.id}`"
|
||||||
>
|
>
|
||||||
<template #carte>
|
<template #carte>
|
||||||
<!-- Métropole -->
|
<!-- Métropole -->
|
||||||
@@ -27,6 +27,7 @@
|
|||||||
:orgs="metropoleOrgs"
|
:orgs="metropoleOrgs"
|
||||||
:selectedId="selectedId"
|
:selectedId="selectedId"
|
||||||
:survolId="survolNum"
|
:survolId="survolNum"
|
||||||
|
:centrage="centrageMetropole"
|
||||||
@select-org="onSelectOrg"
|
@select-org="onSelectOrg"
|
||||||
@survol="(id) => (vf.survolId = id)"
|
@survol="(id) => (vf.survolId = id)"
|
||||||
/>
|
/>
|
||||||
@@ -55,13 +56,22 @@
|
|||||||
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
<ChatbotPlaceholder @highlightOrgs="onHighlightOrgs" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</VueFiches>
|
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ MODAL FICHE (ordinateur) -->
|
<!-- Détail (B12) : la fiche complète vient de la liste (même enregistrement NocoDB que /api/fiche/:id) -->
|
||||||
<FicheModal
|
<template #detail="{ fiche, filtrer }">
|
||||||
v-model="ficheModalOpen"
|
<template v-if="orgParId.get(String(fiche.id))">
|
||||||
:orgId="ficheModalId"
|
<FicheDetail
|
||||||
/>
|
:key="fiche.id"
|
||||||
|
:org="orgParId.get(String(fiche.id))!"
|
||||||
|
compact
|
||||||
|
etiquettes-cliquables
|
||||||
|
@etiquette="(t) => filtrer('fonctions', t)"
|
||||||
|
/>
|
||||||
|
<CommentSection :key="`c-${fiche.id}`" :org-id="Number(fiche.id)" :refresh="commentaires" />
|
||||||
|
<CommentForm :key="`f-${fiche.id}`" :org-id="Number(fiche.id)" @submitted="commentaires++" />
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</VueFiches>
|
||||||
|
|
||||||
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
<!-- ═══════════════════════════════════════ BOUTON CHATBOT FLOTTANT (mobile) -->
|
||||||
<button
|
<button
|
||||||
@@ -171,41 +181,49 @@ const metropoleOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => !o.t
|
|||||||
const outremerOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => o.territoire && DOM_TOM.includes(o.territoire)))
|
const outremerOrgs = computed<Org[]>(() => orgsFiltres.value.filter((o) => o.territoire && DOM_TOM.includes(o.territoire)))
|
||||||
const survolNum = computed(() => (vf.survolId == null ? null : Number(vf.survolId)))
|
const survolNum = computed(() => (vf.survolId == null ? null : Number(vf.survolId)))
|
||||||
|
|
||||||
// ── Sélection ─────────────────────────────────────────────────────────────
|
// ── Sélection (B12) : la fiche ouverte vit dans l'URL (`fiche=`), détail en panneau ──
|
||||||
const selectedId = ref<number | null>(null)
|
const orgParId = computed(() => new Map(orgs.value.map((o) => [String(o.Id), o])))
|
||||||
const ficheModalOpen = ref(false)
|
|
||||||
const ficheModalId = ref<number | null>(null)
|
// Épingle et carte-fiche allumées : la fiche ouverte, ou 5 s celle que le chatbot désigne
|
||||||
|
const surligneId = ref<number | null>(null)
|
||||||
|
const selectedId = computed<number | null>(() => surligneId.value ?? (vf.ficheId != null ? Number(vf.ficheId) : null))
|
||||||
|
|
||||||
|
const commentaires = ref(0)
|
||||||
|
|
||||||
// Ordinateur : FicheModal ; téléphone et tablette : page /fiche/:id (comportement d'avant B11)
|
|
||||||
function onSelectOrg(id: number) {
|
function onSelectOrg(id: number) {
|
||||||
if (typeof window !== 'undefined' && window.innerWidth >= 1024) {
|
vf.ouvrirFiche(id)
|
||||||
selectedId.value = selectedId.value === id ? null : id
|
}
|
||||||
ficheModalId.value = id
|
|
||||||
ficheModalOpen.value = true
|
// « Voir sur la carte » : la bonne sous-vue (Métropole ou Outre-mer), puis l'épingle
|
||||||
return
|
const centrageMetropole = ref<{ id: number; n: number } | null>(null)
|
||||||
}
|
watch(() => vf.centrage, (c) => {
|
||||||
selectedId.value = id
|
if (!c) return
|
||||||
try {
|
const o = orgParId.value.get(c.id)
|
||||||
// Le bouton retour de /fiche/:id revient à la même vue, filtres compris
|
const dom = !!o?.territoire && DOM_TOM.includes(o.territoire)
|
||||||
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
|
if (vf.etat.sousVue !== (dom ? 'outremer' : 'metropole')) vf.setSousVue(dom ? 'outremer' : 'metropole')
|
||||||
} catch {}
|
if (!dom) centrageMetropole.value = { id: Number(c.id), n: c.n }
|
||||||
router.push(`/fiche/${id}`)
|
})
|
||||||
|
|
||||||
|
// Le bouton retour de /fiche/:id (« Ouvrir la page de la fiche ») revient à la même vue
|
||||||
|
if (import.meta.client) {
|
||||||
|
watch(() => route.fullPath, () => {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('nav_back_filters', new URLSearchParams(route.query as Record<string, string>).toString())
|
||||||
|
} catch {}
|
||||||
|
}, { immediate: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Surlignage temporaire (5 s) suite à une réponse chatbot
|
// Surlignage temporaire (5 s) suite à une réponse chatbot
|
||||||
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
let highlightTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const prevSelectedId = ref<number | null>(null)
|
|
||||||
|
|
||||||
function onHighlightOrgs(ids: (number | string)[]) {
|
function onHighlightOrgs(ids: (number | string)[]) {
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
const firstId = typeof ids[0] === 'string' ? parseInt(ids[0], 10) : ids[0]
|
||||||
if (isNaN(firstId)) return
|
if (isNaN(firstId)) return
|
||||||
prevSelectedId.value = selectedId.value
|
surligneId.value = firstId
|
||||||
selectedId.value = firstId
|
|
||||||
if (highlightTimer) clearTimeout(highlightTimer)
|
if (highlightTimer) clearTimeout(highlightTimer)
|
||||||
highlightTimer = setTimeout(() => {
|
highlightTimer = setTimeout(() => {
|
||||||
selectedId.value = prevSelectedId.value
|
surligneId.value = null
|
||||||
prevSelectedId.value = null
|
|
||||||
highlightTimer = null
|
highlightTimer = null
|
||||||
}, 5000)
|
}, 5000)
|
||||||
}
|
}
|
||||||
@@ -231,7 +249,8 @@ onMounted(() => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
})
|
})
|
||||||
|
|
||||||
useHead({ title: 'AEP — Cartographie de l\'écologie politique architecturale' })
|
// Un lien partagé avec fiche= porte le nom de la fiche (onglet, aperçu de lien), dès le rendu serveur
|
||||||
|
useHead({ title: computed(() => (vf.ficheOuverte ? `${vf.ficheOuverte.nom} — AEP` : 'AEP — Cartographie de l\'écologie politique architecturale')) })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -254,8 +254,11 @@
|
|||||||
<!-- Messages de la conversation -->
|
<!-- Messages de la conversation -->
|
||||||
<template v-for="(msg, i) in chatMessages" :key="i">
|
<template v-for="(msg, i) in chatMessages" :key="i">
|
||||||
<div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']">
|
<div :class="['taff-msg', msg.role === 'user' ? 'taff-msg--user' : 'taff-msg--bot']">
|
||||||
<div v-if="msg.role === 'bot'" class="md-content" v-html="renderMd(msg.content)" />
|
<template v-if="msg.role === 'bot'">
|
||||||
<span v-else>{{ msg.content }}</span>
|
<ChatModeBadge :profondeur="msg.profondeur" />
|
||||||
|
<div class="md-content" v-html="renderMd(msg.content)" />
|
||||||
|
</template>
|
||||||
|
<span v-if="msg.role !== 'bot'">{{ msg.content }}</span>
|
||||||
</div>
|
</div>
|
||||||
<!-- Plateformes recommandées -->
|
<!-- Plateformes recommandées -->
|
||||||
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
|
<div v-if="msg.role === 'bot' && msg.recommandations?.length" class="taff-chat-recos">
|
||||||
@@ -275,6 +278,13 @@
|
|||||||
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
|
<div v-if="chatLoading" class="taff-msg taff-msg--bot">
|
||||||
<span class="taff-typing"><span/><span/><span/></span>
|
<span class="taff-typing"><span/><span/><span/></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ChatNewConversation v-if="chatHasHistory && !chatLoading" @reset="clearChat" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mode de réponse -->
|
||||||
|
<div class="taff-chat-mode">
|
||||||
|
<ChatModeToggle v-model="chatProfondeur" :disabled="chatLoading" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input -->
|
<!-- Input -->
|
||||||
@@ -581,6 +591,9 @@ interface ChatMessage {
|
|||||||
role: 'user' | 'bot'
|
role: 'user' | 'bot'
|
||||||
content: string
|
content: string
|
||||||
recommandations?: { id: string; nom: string; raison: string }[]
|
recommandations?: { id: string; nom: string; raison: string }[]
|
||||||
|
profondeur?: string
|
||||||
|
/** Message d'erreur : affiché mais jamais persisté */
|
||||||
|
transient?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const { render: renderMd } = useMarkdown()
|
const { render: renderMd } = useMarkdown()
|
||||||
@@ -588,7 +601,8 @@ const { render: renderMd } = useMarkdown()
|
|||||||
const chatOpen = ref(false)
|
const chatOpen = ref(false)
|
||||||
const chatInput = ref('')
|
const chatInput = ref('')
|
||||||
const chatLoading = ref(false)
|
const chatLoading = ref(false)
|
||||||
const chatMessages = ref<ChatMessage[]>([])
|
const { messages: chatMessages, hasHistory: chatHasHistory, clear: clearChat } = useChatSession<ChatMessage>('taff')
|
||||||
|
const chatProfondeur = useChatProfondeur('taff')
|
||||||
const chatMessagesEl = ref<HTMLElement | null>(null)
|
const chatMessagesEl = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
async function sendChat() {
|
async function sendChat() {
|
||||||
@@ -597,20 +611,22 @@ async function sendChat() {
|
|||||||
chatMessages.value.push({ role: 'user', content: q })
|
chatMessages.value.push({ role: 'user', content: q })
|
||||||
chatInput.value = ''
|
chatInput.value = ''
|
||||||
chatLoading.value = true
|
chatLoading.value = true
|
||||||
|
const mode = chatProfondeur.value
|
||||||
await nextTick()
|
await nextTick()
|
||||||
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
|
chatMessagesEl.value?.scrollTo({ top: chatMessagesEl.value.scrollHeight, behavior: 'smooth' })
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>(
|
const res = await $fetch<{ reponse_texte: string; plateformes_recommandees: { id: string; nom: string; raison: string }[] }>(
|
||||||
'/api/chatbot-taff',
|
'/api/chatbot-taff',
|
||||||
{ method: 'POST', body: { question: q } }
|
{ method: 'POST', body: { question: q, profondeur: mode } }
|
||||||
)
|
)
|
||||||
chatMessages.value.push({
|
chatMessages.value.push({
|
||||||
role: 'bot',
|
role: 'bot',
|
||||||
content: res.reponse_texte,
|
content: res.reponse_texte,
|
||||||
recommandations: res.plateformes_recommandees ?? [],
|
recommandations: res.plateformes_recommandees ?? [],
|
||||||
|
profondeur: mode,
|
||||||
})
|
})
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.' })
|
chatMessages.value.push({ role: 'bot', content: e?.data?.statusMessage ?? 'Erreur — réessaie dans un instant.', transient: true })
|
||||||
} finally {
|
} finally {
|
||||||
chatLoading.value = false
|
chatLoading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@@ -968,6 +984,8 @@ const parsedDescription = computed(() => {
|
|||||||
40% { transform: translateY(-5px); opacity: 1; }
|
40% { transform: translateY(-5px); opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.taff-chat-mode { padding: 0.5rem 1rem 0; }
|
||||||
|
|
||||||
.taff-chat-input-wrap {
|
.taff-chat-input-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
// Test du garde-fou des citations (server/utils/citations.ts). Usage : node scripts/test-citations.mjs
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { verifierCitations, contenusChunks } from '../server/utils/citations.ts'
|
||||||
|
|
||||||
|
const ctx = ["La technique est devenue autonome, et forme un monde dévorant.\r\nIl n’y a plus de liberté de choix. Plus on parle d’une valeur, plus c'est le signe de son absence."]
|
||||||
|
const t = (rep, c = ctx) => verifierCitations(rep, c)
|
||||||
|
|
||||||
|
// retrouvée (apostrophes, casse, ponctuation de bord, retours chariot) : gardée
|
||||||
|
let r = t('Ellul écrit : « Il n\'y a plus de liberté de choix ! » donc.')
|
||||||
|
assert.equal(r.gardees, 1); assert.equal(r.retirees, 0); assert.ok(r.texte.includes('«'))
|
||||||
|
// introuvable : guillemets retirés, texte conservé
|
||||||
|
r = t('Il dit : « La technique est une machine à fabriquer du pouvoir social inévitable ». Fin.')
|
||||||
|
assert.equal(r.retirees, 1); assert.equal(r.texte, 'Il dit : La technique est une machine à fabriquer du pouvoir social inévitable. Fin.')
|
||||||
|
// guillemets droits et anglais
|
||||||
|
r = t('"Une phrase inventée qui ne figure nulle part dans le texte" et “Une autre phrase inventée qui ne figure nulle part ici”')
|
||||||
|
assert.equal(r.retirees, 2); assert.ok(!/["“”]/.test(r.texte))
|
||||||
|
// court (< 6 mots) : intact, non compté
|
||||||
|
r = t('Le « rift métabolique » et la « ville intelligente ».')
|
||||||
|
assert.equal(r.gardees + r.retirees, 0); assert.ok(r.texte.includes('« rift métabolique »'))
|
||||||
|
// élision [...] : chaque morceau doit exister
|
||||||
|
r = t('« Plus on parle d\'une valeur [...] le signe de son absence »')
|
||||||
|
assert.equal(r.gardees, 1)
|
||||||
|
r = t('« Plus on parle d\'une valeur [...] le signe de sa présence éclatante »')
|
||||||
|
assert.equal(r.retirees, 1)
|
||||||
|
// pas de correspondance en milieu de mot
|
||||||
|
r = t('« ormation autonome, et forme un monde dévorant »')
|
||||||
|
assert.equal(r.retirees, 1)
|
||||||
|
// aucun contexte : rien de long n'est gardé
|
||||||
|
r = t('« Il n\'y a plus de liberté de choix »', [])
|
||||||
|
assert.equal(r.retirees, 1)
|
||||||
|
// contenusChunks : tableau de chaînes ou chaîne
|
||||||
|
assert.deepEqual(contenusChunks([{ content: ['a', 'b'] }, { content: 'c' }, {}, null]), ['a', 'b', 'c'])
|
||||||
|
assert.deepEqual(contenusChunks(undefined), [])
|
||||||
|
console.log('citations : tous les cas passent')
|
||||||
@@ -128,5 +128,63 @@ ok(eq(gt.map((x) => x.libelle), ['Martinique', 'Métropole']), 'groupe territoir
|
|||||||
ok(V.regrouper(fiches, null, cfg).length === 1, 'sans regroupement : un seul bloc')
|
ok(V.regrouper(fiches, null, cfg).length === 1, 'sans regroupement : un seul bloc')
|
||||||
ok(eq(ids(V.ordreFiches(g)), [1, 4, 2, 3, 5]), 'ordre fiche à fiche = ordre affiché des groupes (B12)')
|
ok(eq(ids(V.ordreFiches(g)), [1, 4, 2, 3, 5]), 'ordre fiche à fiche = ordre affiché des groupes (B12)')
|
||||||
|
|
||||||
|
// ── Fiche ouverte : URL, fiche à fiche, historique (B12-M2/M3) ──
|
||||||
|
ok(V.lireFiche({ fiche: '412' }) === '412' && V.lireFiche({ fiche: 'f1-rotor' }) === 'f1-rotor', 'ouverture par URL : fiche= lu (id numérique ou texte)')
|
||||||
|
ok(V.lireFiche({}) === null && V.lireFiche({ fiche: '' }) === null && V.lireFiche({ fiche: ' ' }) === null, 'fiche= absent ou vide → pas de fiche')
|
||||||
|
ok(V.lireFiche({ fiche: ['7', '8'] }) === '7', 'fiche= répété → la première valeur')
|
||||||
|
const qListe = { vue: 'fiches', fonctions: 'Juridique,Formation', tri: 'nom' }
|
||||||
|
ok(eq(V.queryAvecFiche(qListe, 412), { ...qListe, fiche: '412' }), 'queryAvecFiche pose fiche= et garde filtres, tri, vue')
|
||||||
|
ok(eq(V.queryAvecFiche({ ...qListe, fiche: '412' }, null), qListe), 'queryAvecFiche(null) retire fiche= et rien d’autre')
|
||||||
|
const qEcrite = V.ecrireQuery({ ...V.lireQuery({ ...qListe, fiche: '412' }, cfg), tri: 'pertinence' }, cfg, { ...qListe, fiche: '412' })
|
||||||
|
ok(qEcrite.fiche === '412' && qEcrite.tri === 'pertinence', 'un changement de tri garde la fiche ouverte')
|
||||||
|
ok(V.empreinteListe({ ...qListe, fiche: '1' }) === V.empreinteListe({ fiche: '9', tri: 'nom', fonctions: 'Juridique,Formation', vue: 'fiches' }), 'empreinte de la liste : indépendante de fiche= et de l’ordre des clés')
|
||||||
|
|
||||||
|
const ordre = V.ordreFiches(g) // [1, 4, 2, 3, 5]
|
||||||
|
const p = V.positionFiche(ordre, '2')
|
||||||
|
ok(p && p.rang === 3 && p.total === 5 && p.precedente.id === 4 && p.suivante.id === 3, '« 3 / 5 » : précédente et suivante dans l’ordre courant')
|
||||||
|
ok(V.positionFiche(ordre, 1).precedente === null && V.positionFiche(ordre, 5).suivante === null, 'pas de bouclage aux extrémités')
|
||||||
|
const ordreFiltre = V.ordreFiches(V.regrouper(V.filtrerFiches(fiches, etat0({ filtres: { fonctions: ['Juridique'], echelle: [] } }), cfg), null, cfg))
|
||||||
|
ok(V.positionFiche(ordreFiltre, '4') === null, 'fiche hors filtres → position null (« hors de vos filtres », sans précédente/suivante)')
|
||||||
|
ok(V.positionFiche(ordre, null) === null && V.positionFiche(ordre, '999') === null, 'pas de fiche ou id inconnu → null')
|
||||||
|
|
||||||
|
// Historique simulé, avec les mêmes décisions que composables/useVueFiches.ts
|
||||||
|
function navigateur(depart) {
|
||||||
|
const pile = [depart]
|
||||||
|
let i = 0
|
||||||
|
let liste = null // listeALOuverture
|
||||||
|
const cur = () => pile[i]
|
||||||
|
return {
|
||||||
|
cur,
|
||||||
|
longueur: () => i + 1,
|
||||||
|
ouvrir(id) {
|
||||||
|
const mode = V.modeOuverture(V.lireFiche(cur()))
|
||||||
|
if (mode === 'push') { liste = V.empreinteListe(cur()); pile.splice(i + 1); pile.push(V.queryAvecFiche(cur(), id)); i++ }
|
||||||
|
else pile[i] = V.queryAvecFiche(cur(), id)
|
||||||
|
},
|
||||||
|
filtrer(q) { pile[i] = q },
|
||||||
|
fermer() {
|
||||||
|
const mode = V.modeFermeture(liste != null, liste === V.empreinteListe(cur()))
|
||||||
|
liste = null
|
||||||
|
if (mode === 'back') i--
|
||||||
|
else pile[i] = V.queryAvecFiche(cur(), null)
|
||||||
|
},
|
||||||
|
retour() { i--; if (V.lireFiche(cur()) == null) liste = null },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let nav = navigateur(qListe)
|
||||||
|
nav.ouvrir(1); nav.ouvrir(4); nav.ouvrir(2)
|
||||||
|
ok(nav.longueur() === 2 && nav.cur().fiche === '2', 'ouvrir puis deux fois « suivante » : une seule entrée d’historique ajoutée')
|
||||||
|
nav.retour()
|
||||||
|
ok(nav.longueur() === 1 && V.lireFiche(nav.cur()) === null && eq(nav.cur(), qListe), 'retour arrière (téléphone) : la fiche se ferme, la liste et ses filtres restent')
|
||||||
|
nav = navigateur(qListe)
|
||||||
|
nav.ouvrir(1); nav.fermer()
|
||||||
|
ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'fermer une fiche ouverte depuis la liste : retour en arrière, historique propre')
|
||||||
|
nav = navigateur({ ...qListe, fiche: '412' })
|
||||||
|
nav.fermer()
|
||||||
|
ok(nav.longueur() === 1 && eq(nav.cur(), qListe), 'lien partagé : fermer retire fiche= sur place, sans quitter la page')
|
||||||
|
nav = navigateur(qListe)
|
||||||
|
nav.ouvrir(1); nav.filtrer({ ...nav.cur(), echelle: 'National' }); nav.fermer()
|
||||||
|
ok(nav.cur().echelle === 'National' && V.lireFiche(nav.cur()) === null, 'filtre changé fiche ouverte : fermer garde le nouveau filtre (pas de retour arrière)')
|
||||||
|
|
||||||
console.log(`\n${n - fail}/${n}`)
|
console.log(`\n${n - fail}/${n}`)
|
||||||
process.exit(fail ? 1 : 0)
|
process.exit(fail ? 1 : 0)
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Contrôle des citations d'un chatbot LightRAG : chaque segment entre guillemets
|
||||||
|
* de la réponse doit figurer mot pour mot dans le contexte réellement récupéré.
|
||||||
|
*
|
||||||
|
* Usage :
|
||||||
|
* node scripts/verif-citations.mjs --rag http://HOTE:9621 --query "Cite ..." \
|
||||||
|
* [--route http://localhost:3334/api/chatbot-pensees] [--mode hybrid] [--auteur slug] \
|
||||||
|
* [--response-file reponse.txt] [--ctx-query-file autre-query.txt] [--min-mots 6] [--max-total-tokens 40000 (mode approfondi)] [--json]
|
||||||
|
*
|
||||||
|
* - Sans --response-file : la réponse est demandée à --route (POST {query, mode, auteur_slug}).
|
||||||
|
* - Contexte : POST {rag}/query {query, mode, only_need_context:true}. --ctx-query-file ajoute
|
||||||
|
* un second contexte (ex. la requête telle qu'envoyée avant amendement, préambule inclus) ;
|
||||||
|
* un segment est « trouvé » s'il figure dans l'un ou l'autre.
|
||||||
|
* - Segment >= min-mots (6) absent du contexte = inventé (algorithme : server/utils/citations.ts, partagé avec la route). Plus court : signalé, non compté.
|
||||||
|
* Lecture seule côté LightRAG. Réutilisable pour l'instance 9623 (--rag http://HOTE:9623).
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
// Node >= 22.18 charge le .ts directement (syntaxe effaçable uniquement dans citations.ts).
|
||||||
|
import { verifierCitations } from '../server/utils/citations.ts'
|
||||||
|
|
||||||
|
const args = Object.fromEntries(process.argv.slice(2).reduce((a, x, i, arr) => {
|
||||||
|
if (x.startsWith('--')) a.push([x.slice(2), arr[i + 1] && !arr[i + 1].startsWith('--') ? arr[i + 1] : true])
|
||||||
|
return a
|
||||||
|
}, []))
|
||||||
|
if (!args.rag || !args.query) { console.error('--rag et --query requis'); process.exit(2) }
|
||||||
|
const mode = args.mode || 'hybrid'
|
||||||
|
const minMots = Number(args['min-mots'] || 6)
|
||||||
|
|
||||||
|
async function post(url, body, timeout = 120000) {
|
||||||
|
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) })
|
||||||
|
if (!r.ok) throw new Error(`${url} -> HTTP ${r.status}`)
|
||||||
|
return r.json()
|
||||||
|
}
|
||||||
|
async function contexte(q) {
|
||||||
|
const j = await post(`${args.rag}/query`, { query: q, mode, only_need_context: true, ...(args['max-total-tokens'] ? { max_total_tokens: Number(args['max-total-tokens']) } : {}) })
|
||||||
|
return typeof j.response === 'string' ? j.response : JSON.stringify(j)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reponse
|
||||||
|
if (args['response-file']) reponse = readFileSync(args['response-file'], 'utf8')
|
||||||
|
else {
|
||||||
|
if (!args.route) { console.error('--route ou --response-file requis'); process.exit(2) }
|
||||||
|
const b = { query: args.query, mode }
|
||||||
|
if (args.auteur) b.auteur_slug = args.auteur
|
||||||
|
reponse = (await post(args.route, b)).response
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctxs = [await contexte(args.query)]
|
||||||
|
if (args['ctx-query-file']) ctxs.push(await contexte(readFileSync(args['ctx-query-file'], 'utf8')))
|
||||||
|
// Même fonction pure que la route (server/utils/citations.ts) : un seul algorithme, deux usages.
|
||||||
|
const v = verifierCitations(reponse, ctxs, minMots)
|
||||||
|
const lignes = v.segments.map(l => ({ segment: l.segment, mots: l.mots, trouve: l.trouve }))
|
||||||
|
const longs = lignes.filter(l => l.mots >= minMots)
|
||||||
|
const res = {
|
||||||
|
query: args.query, mode,
|
||||||
|
segments: lignes.length, longs: longs.length,
|
||||||
|
longs_trouves: longs.filter(l => l.trouve).length,
|
||||||
|
inventes: longs.filter(l => !l.trouve).length,
|
||||||
|
courts_non_comptes: lignes.length - longs.length,
|
||||||
|
contexte_chars: ctxs.map(c => c.length),
|
||||||
|
detail: lignes, reponse,
|
||||||
|
}
|
||||||
|
if (args.json) console.log(JSON.stringify(res, null, 2))
|
||||||
|
else {
|
||||||
|
console.log(`Q: ${res.query}\nsegments=${res.segments} longs(>=${minMots} mots)=${res.longs} trouvés=${res.longs_trouves} INVENTÉS=${res.inventes} courts=${res.courts_non_comptes}`)
|
||||||
|
for (const l of lignes) console.log(` [${l.mots >= minMots ? (l.trouve ? 'OK ' : 'INV') : 'crt'}] (${l.mots}) ${l.segment.slice(0, 160)}`)
|
||||||
|
}
|
||||||
@@ -6,6 +6,18 @@
|
|||||||
import { readFileSync } from 'node:fs'
|
import { readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
|
import { profondeurDuBody, REGLE_GUILLEMETS } from '~/server/utils/bifrost'
|
||||||
|
import { verifierCitations, contenusChunks } from '~/server/utils/citations'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Règle citations — commune aux 4 préambules (mesure M7 du 05/10 : le modèle invente des
|
||||||
|
* citations entre guillemets quand l'extrait n'est pas dans le contexte récupéré).
|
||||||
|
*/
|
||||||
|
const REGLE_CITATIONS = `RÈGLE DES CITATIONS (prioritaire sur toute autre consigne) :
|
||||||
|
- Ne mets entre guillemets (« ») que ce qui figure MOT POUR MOT dans le contexte fourni ci-dessus. Jamais de citation reconstituée de mémoire, de paraphrase ou de résumé entre guillemets. Même si la question demande des phrases « exactes » ou « mot pour mot », ne cède jamais : mieux vaut peu de citations, ou aucune, qu'une citation approximative.
|
||||||
|
- Avant d'écrire un guillemet, repère dans le contexte le passage exact : si tu ne le retrouves pas à l'identique, ne le cite pas. Au plus 3 citations, les plus courtes et les plus sûres ; recopie-les sans les raccourcir, sans les enchaîner, sans les compléter.
|
||||||
|
- Si aucun passage correspondant n'est disponible dans le contexte, dis explicitement « je ne cite pas : aucun passage exact n'est disponible dans les extraits » et reformule sans guillemets, en indiquant qu'il s'agit d'une synthèse.
|
||||||
|
- Nomme l'œuvre dont provient le passage dans le contexte, pas une autre.`
|
||||||
|
|
||||||
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
const SYSTEM_PREFACE_PENSEES = `Tu es un agent du RAG Pensées Écologiques, infrastructure militante du collectif trans-former.fr.
|
||||||
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
Tu réponds en t'appuyant STRICTEMENT sur le corpus ingéré (auteurs FRACAS Bonpote : écosocialisme, éco-anarchisme, écoféminismes, écologies décoloniales, technocritique, pensées du vivant, décroissance...).
|
||||||
@@ -40,6 +52,28 @@ Règles :
|
|||||||
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
- Ton praticien militant : direct, pas neutre, ancré dans la pratique architecturale.
|
||||||
- Réponse en français, dense, sans délayage.`
|
- Réponse en français, dense, sans délayage.`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Posture « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9).
|
||||||
|
* Ajoutée APRÈS le préambule du corpus et AVANT REGLE_CITATIONS (qui reste prioritaire).
|
||||||
|
* Pensées n'est pas servi par Bifrost : le LLM de génération est réglé côté LightRAG, donc
|
||||||
|
* ce mode change la consigne et les paramètres de récupération, pas le modèle.
|
||||||
|
*/
|
||||||
|
const POSTURE_REFLEXION = `MODE RÉFLEXION : l'usager veut penser, pas seulement s'informer.
|
||||||
|
- Mets les idées en relation : reformule d'abord ce que la question engage, puis fais dialoguer les auteurs et les écoles présents dans le contexte.
|
||||||
|
- Nomme les tensions et les désaccords entre écoles (ce qu'elles partagent, là où elles divergent, ce que chacune ne voit pas), sans trancher à la place de l'usager.
|
||||||
|
- Reste strictement dans le corpus fourni : si une idée n'y est pas, dis-le au lieu de la compléter de mémoire.
|
||||||
|
- Termine par 2 questions ouvertes qui aident l'usager à situer sa propre position.
|
||||||
|
- Réponse structurée en paragraphes courts, 450 mots maximum.
|
||||||
|
- ${REGLE_GUILLEMETS}`
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paramètres de récupération LightRAG du mode réflexion. Mesuré le 09/10 (contexte seul, hybrid) :
|
||||||
|
* top_k 60 / chunk_top_k 30 ne changent rien (115,7 → 115,9 Ko, le budget de tokens plafonne) ;
|
||||||
|
* max_total_tokens 50000 monte le contexte à 153 Ko. On prend 40000 : plus de matière pour croiser
|
||||||
|
* les écoles sans approcher la fenêtre du LLM de génération (réglé côté serveur).
|
||||||
|
*/
|
||||||
|
const PARAMS_RAG_REFLEXION = { max_total_tokens: 40000 }
|
||||||
|
|
||||||
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
function buildPrefaceAuteur(nomAuteur: string, slug: string): string {
|
||||||
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
return `Tu réponds EXCLUSIVEMENT depuis les livres de ${nomAuteur} présents dans le RAG (fichiers commençant par "${slug}__").
|
||||||
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
Si la question sort du périmètre de cet auteur, indique-le et propose de l'aborder sans le hashtag pour interroger la carte entière. Reste fidèle au style et à la pensée de ${nomAuteur}. Cite toujours le livre.
|
||||||
@@ -88,8 +122,10 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const query: string = body.query.trim()
|
const query: string = body.query.trim()
|
||||||
|
// jamais body.mode ici : réservé au mode de recherche LightRAG (hybrid…)
|
||||||
|
const profondeur = profondeurDuBody({ profondeur: body.profondeur })
|
||||||
const mode: string = body.mode || 'hybrid'
|
const mode: string = body.mode || 'hybrid'
|
||||||
const corpus: string = body.corpus || 'both'
|
const corpus: string = body.corpus || 'pensees'
|
||||||
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
const ragUrl: string = (config.ragPeUrl as string) || 'http://localhost:9621'
|
||||||
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
|
const auteurSlug: string | null = body.auteur_slug?.trim().toLowerCase() || null
|
||||||
|
|
||||||
@@ -117,8 +153,20 @@ export default defineEventHandler(async (event) => {
|
|||||||
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
|
throw createError({ statusCode: 503, message: 'RAG indisponible pour l\'instant — réessaie dans quelques minutes.' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const ragQuery = `${systemPreface}\n\nQuestion : ${query}`
|
// user_prompt (LightRAG) : instructions ajoutées au prompt de génération, sans polluer la recherche
|
||||||
const ragBody: Record<string, any> = { query: ragQuery, mode }
|
// (vérifié sur 9621 le 09/10 : une consigne de fin de réponse est appliquée). La requête de
|
||||||
|
// recherche est la question seule.
|
||||||
|
const ragBody: Record<string, any> = {
|
||||||
|
query,
|
||||||
|
mode,
|
||||||
|
// textes des chunks utilisés : sert au garde-fou des citations (server/utils/citations.ts)
|
||||||
|
include_references: true,
|
||||||
|
include_chunk_content: true,
|
||||||
|
user_prompt: profondeur === 'approfondi'
|
||||||
|
? `${systemPreface}\n\n${POSTURE_REFLEXION}\n\n${REGLE_CITATIONS}`
|
||||||
|
: `${systemPreface}\n\n${REGLE_CITATIONS}`,
|
||||||
|
}
|
||||||
|
if (profondeur === 'approfondi') Object.assign(ragBody, PARAMS_RAG_REFLEXION)
|
||||||
if (auteurSlug && nomAuteurMatch) {
|
if (auteurSlug && nomAuteurMatch) {
|
||||||
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
ragBody.hl_keywords = [nomAuteurMatch, auteurSlug]
|
||||||
ragBody.ll_keywords = [auteurSlug]
|
ragBody.ll_keywords = [auteurSlug]
|
||||||
@@ -149,8 +197,15 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Garde-fou : un segment long entre guillemets absent des chunks récupérés perd ses guillemets.
|
||||||
|
const verif = verifierCitations(String(ragResponse.response ?? ''), contenusChunks(ragResponse.references))
|
||||||
|
if (verif.retirees > 0) {
|
||||||
|
console.warn(`[chatbot-pensees] ${verif.retirees} citation(s) non retrouvée(s) dans le contexte, guillemets retirés (${verif.gardees} gardée(s))`)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
response: ragResponse.response ?? '',
|
response: verif.texte,
|
||||||
|
citations: { gardees: verif.gardees, retirees: verif.retirees },
|
||||||
mode,
|
mode,
|
||||||
corpus,
|
corpus,
|
||||||
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
|
auteur: auteurSlug && nomAuteurMatch ? { slug: auteurSlug, nom: nomAuteurMatch } : null,
|
||||||
@@ -158,5 +213,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
auteur_chunks: auteurSlug && nomAuteurMatch ? { on_target: chunksOnTarget, off_target: chunksOffTarget } : null,
|
||||||
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
filter: { couche: body.filter_couche ?? null, ecole: body.filter_ecole ?? null },
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
|
...(import.meta.dev
|
||||||
|
? { debug: { profondeur, user_prompt_chars: ragBody.user_prompt.length, rag_params: profondeur === 'approfondi' ? PARAMS_RAG_REFLEXION : null } }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Rollup
|
// @ts-ignore — JSON import résolu par Rollup
|
||||||
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
import reseauxData from '../../public/data/reseaux-bifurcation.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface Structure {
|
interface Structure {
|
||||||
id: string
|
id: string
|
||||||
@@ -41,6 +41,29 @@ FORMAT :
|
|||||||
STRUCTURES DISPONIBLES :
|
STRUCTURES DISPONIBLES :
|
||||||
{{STRUCTURES_JSON}}`
|
{{STRUCTURES_JSON}}`
|
||||||
|
|
||||||
|
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
|
||||||
|
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller archi, au service des architectes engagés dans la transition écologique. Tu connais le réseau AEP (Architecture d'Écologie Politique) — une cartographie des collectifs, agences, initiatives et réseaux qui portent une vision alternative de l'architecture en France et en Europe.
|
||||||
|
|
||||||
|
POSTURE : avant de recommander des structures, tu questionnes le projet de l'usager. Tu reformules ce que tu comprends de son projet, tu nommes ce qui reste flou ou en tension (échelle, maîtrise d'ouvrage, filière de matériaux, rapport au territoire, posture professionnelle, moyens), tu poses 2 ou 3 questions précises qui l'aident à se situer, PUIS tu orientes vers les structures du contexte qui correspondent à ce qu'il a dit, en expliquant ce que chacune lui apporterait ou lui ferait choisir.
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. Ne cite QUE des structures présentes dans le contexte ci-dessous.
|
||||||
|
2. ${REGLE_GUILLEMETS}
|
||||||
|
3. Sois direct et engagé — c'est l'esprit AEP — mais n'affirme pas ce que tu ne sais pas de son projet : demande.
|
||||||
|
4. Réponse de 300 mots maximum, en français, en prose, tutoiement.
|
||||||
|
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
FORMAT :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, tension, questions, puis structures",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": "slug-id", "nom": "Nom structure", "explication": "Pourquoi en 1 phrase" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
STRUCTURES DISPONIBLES :
|
||||||
|
{{STRUCTURES_JSON}}`
|
||||||
|
|
||||||
function scoreStructure(s: Structure, keywords: string[]): number {
|
function scoreStructure(s: Structure, keywords: string[]): number {
|
||||||
if (keywords.length === 0) return 1
|
if (keywords.length === 0) return 1
|
||||||
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
const haystack = [s.nom, s.famille_principale, s.description_courte, s.description_longue, (s.hashtags ?? []).join(' '), s.ville, s.pays]
|
||||||
@@ -62,7 +85,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
if (!question || question.length < 5) throw createError({ statusCode: 400, message: 'Question trop courte.' })
|
||||||
const tier = pickBifrostTier(body?.mode)
|
const profondeur = profondeurDuBody(body)
|
||||||
|
const tier = pickBifrostTier(profondeur)
|
||||||
|
|
||||||
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
const structures: Structure[] = ((reseauxData as any).structures ?? [])
|
||||||
const keywords = extractKeywords(question)
|
const keywords = extractKeywords(question)
|
||||||
@@ -82,13 +106,14 @@ export default defineEventHandler(async (event) => {
|
|||||||
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
description: (s.description_courte ?? s.description_longue ?? '').slice(0, 200),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const bifrostUrl = config.bifrostUrl as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
const bifrostVk = config.bifrostVk as string
|
const bifrostVk = config.bifrostVk as string
|
||||||
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
|
let debugExtra: Record<string, unknown> = {}
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<BifrostChatResponse>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
`${bifrostUrl}/v1/chat/completions`,
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
@@ -99,7 +124,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
max_tokens: bifrostMaxTokens(700, profondeur),
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -109,6 +134,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
mistralRaw = bifrostContent(res, 'chatbot-reseaux')
|
||||||
|
debugExtra = bifrostDebug(res, 'chatbot-reseaux', profondeur)
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
throw createError({ statusCode: 502, message: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
}
|
}
|
||||||
@@ -122,6 +148,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
nom: r.nom ?? structures.find(s => s.id === r.id)?.nom ?? r.id,
|
||||||
explication: r.explication ?? '',
|
explication: r.explication ?? '',
|
||||||
})),
|
})),
|
||||||
|
...debugExtra,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", fiches_recommandees: [] }
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// @ts-ignore — JSON import résolu par Vite/Rollup
|
// @ts-ignore — JSON import résolu par Vite/Rollup
|
||||||
import taffData from '../../public/data/plateformes-taff.json'
|
import taffData from '../../public/data/plateformes-taff.json'
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
interface PlateformeMinimal {
|
interface PlateformeMinimal {
|
||||||
id: string
|
id: string
|
||||||
@@ -49,6 +49,29 @@ FORMAT :
|
|||||||
PLATEFORMES DISPONIBLES :
|
PLATEFORMES DISPONIBLES :
|
||||||
{{PLATEFORMES_JSON}}`
|
{{PLATEFORMES_JSON}}`
|
||||||
|
|
||||||
|
// Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9). Même format JSON.
|
||||||
|
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller pour les architectes indépendants français qui cherchent des missions. Tu connais toutes les plateformes de mise en relation architecte↔particulier et les agrégateurs d'appels d'offres publics référencés par AEP (Architecture d'Écologie Politique).
|
||||||
|
|
||||||
|
POSTURE : avant de recommander une plateforme, tu aides l'architecte à clarifier ce qu'il cherche et qui il est : type de missions visées (particuliers, public, réno, neuf), volume et rythme, statut et assurances, ce qu'il accepte de céder (commission, abonnement, temps de réponse), ses valeurs. Tu reformules son profil tel que tu le comprends, tu nommes un arbitrage que sa demande laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer, PUIS tu orientes vers LA ou LES plateformes du contexte qui correspondent, avec leurs limites.
|
||||||
|
|
||||||
|
RÈGLES :
|
||||||
|
1. Ne recommande QUE des plateformes présentes dans le contexte ci-dessous. Si l'une est « À éviter », signale-le clairement.
|
||||||
|
2. ${REGLE_GUILLEMETS}
|
||||||
|
3. Sois direct et opinionné — c'est la valeur d'AEP — mais ne présume pas de ce que tu ignores de son profil : demande.
|
||||||
|
4. Réponse de 300 mots maximum, en français, en prose, ton conseiller pair, tutoiement.
|
||||||
|
5. Retourne UNIQUEMENT un JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
FORMAT :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose, max 300 mots : reformulation, arbitrage, questions, puis plateformes",
|
||||||
|
"plateformes_recommandees": [
|
||||||
|
{ "id": "slug-kebab", "nom": "Nom plateforme", "raison": "Pourquoi cette plateforme en 1 phrase" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
PLATEFORMES DISPONIBLES :
|
||||||
|
{{PLATEFORMES_JSON}}`
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
export default defineEventHandler(async (event) => {
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
@@ -67,7 +90,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
if (!question || question.length < 5) {
|
if (!question || question.length < 5) {
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
||||||
}
|
}
|
||||||
const tier = pickBifrostTier(body?.mode)
|
const profondeur = profondeurDuBody(body)
|
||||||
|
const tier = pickBifrostTier(profondeur)
|
||||||
|
|
||||||
// Données bundlées statiquement à la compilation (import JSON)
|
// Données bundlées statiquement à la compilation (import JSON)
|
||||||
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
const plateformes: PlateformeMinimal[] = ((taffData as any).plateformes ?? []).map((p: any) => ({
|
||||||
@@ -91,7 +115,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
justification: p.scoring.justification_tag,
|
justification: p.scoring.justification_tag,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
|
||||||
|
|
||||||
const bifrostUrl = config.bifrostUrl as string
|
const bifrostUrl = config.bifrostUrl as string
|
||||||
const bifrostVk = config.bifrostVk as string
|
const bifrostVk = config.bifrostVk as string
|
||||||
@@ -100,6 +124,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mistralRaw: string
|
let mistralRaw: string
|
||||||
|
let debugExtra: Record<string, unknown> = {}
|
||||||
try {
|
try {
|
||||||
const res = await $fetch<BifrostChatResponse>(
|
const res = await $fetch<BifrostChatResponse>(
|
||||||
`${bifrostUrl}/v1/chat/completions`,
|
`${bifrostUrl}/v1/chat/completions`,
|
||||||
@@ -110,7 +135,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 700 + REASONING_MARGIN_TOKENS,
|
max_tokens: bifrostMaxTokens(700, profondeur),
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -120,6 +145,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
mistralRaw = bifrostContent(res, 'chatbot-taff')
|
||||||
|
debugExtra = bifrostDebug(res, 'chatbot-taff', profondeur)
|
||||||
} catch {
|
} catch {
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
throw createError({ statusCode: 502, statusMessage: 'Erreur IA — réessaie dans quelques instants.' })
|
||||||
}
|
}
|
||||||
@@ -133,6 +159,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
nom: r.nom ?? plateformes.find(p => p.id === r.id)?.nom ?? r.id,
|
||||||
raison: r.raison ?? '',
|
raison: r.raison ?? '',
|
||||||
})),
|
})),
|
||||||
|
...debugExtra,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
return { reponse_texte: "Je n'ai pas pu analyser ta demande.", plateformes_recommandees: [] }
|
||||||
|
|||||||
@@ -1,194 +0,0 @@
|
|||||||
/**
|
|
||||||
* POST /api/chatbot-v2
|
|
||||||
*
|
|
||||||
* Chatbot V2 - Embedding-based search sur structures bifurcation
|
|
||||||
* Coexiste avec /api/chatbot (keyword NocoDB) pendant la transition.
|
|
||||||
*
|
|
||||||
* SETUP AVANT DEPLOY :
|
|
||||||
* cd nav-carte && MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js
|
|
||||||
* Coût estimé : ~0.10 EUR pour 120 fiches
|
|
||||||
*
|
|
||||||
* Flow :
|
|
||||||
* 1. Rate limit (réutilise checkRateLimitJson, 10 req/IP/jour)
|
|
||||||
* 2. Embed la query via Mistral Embed (mistral-embed)
|
|
||||||
* 3. Top-5 cosine similarity sur embeddings-v2.json
|
|
||||||
* 4. Si embeddings absents : réponse graceful (v2_ready: false)
|
|
||||||
* 5. Construit contexte RAG depuis les fiches candidates
|
|
||||||
* 6. Génère réponse Mistral Small (json_object)
|
|
||||||
* 7. Retourne { reponse_texte, fiches_recommandees, sources, v2_ready }
|
|
||||||
*
|
|
||||||
* Variables d'env :
|
|
||||||
* MISTRAL_API_KEY - Clé Mistral (partagée avec chatbot v1)
|
|
||||||
* RAG_V1_ENABLED - true/false (défaut: true) - coexistence pendant transition
|
|
||||||
* RAG_V1_DEPRECATION_DATE - Date prévue deprecation v1 (ex: 2026-05-18)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
|
||||||
import { loadEmbeddingsV2, topKSearch } from '~/server/utils/vectorSearch'
|
|
||||||
|
|
||||||
// ── System prompt V2 ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT_V2 = `Tu es un assistant pour la carte des réseaux de bifurcation en architecture (projet AEP).
|
|
||||||
Tu réponds aux questions sur les structures, les pratiques, les pensées écologiques.
|
|
||||||
|
|
||||||
Règles :
|
|
||||||
- Cite chaque structure par son nom exact et son fiche_id
|
|
||||||
- Indique la famille (1-5) entre parenthèses après chaque nom
|
|
||||||
- Reste sobre et descriptif - pas militant agressif
|
|
||||||
- Tirets longs interdits : utilise des - ou des ;
|
|
||||||
- Max 200 mots par réponse
|
|
||||||
- Si hors-scope (pas archi/habiter/écologie), redirige poliment vers la carte
|
|
||||||
- Retourne UNIQUEMENT un JSON valide, sans texte avant ou après
|
|
||||||
|
|
||||||
Familles :
|
|
||||||
1 - Réemploi et filières
|
|
||||||
2 - Frugalité et low-tech
|
|
||||||
3 - Architecture sociale et précarités
|
|
||||||
4 - Collectifs, écolieux et AMO
|
|
||||||
5 - Urbanisme de transition et territoires
|
|
||||||
|
|
||||||
FORMAT DE SORTIE :
|
|
||||||
{
|
|
||||||
"reponse_texte": "Ta réponse en prose (max 200 mots)",
|
|
||||||
"fiches_recommandees": [
|
|
||||||
{ "fiche_id": "f1-rotor", "nom": "Rotor", "explication": "1-2 phrases pourquoi cette fiche" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
CONTEXTE - Structures disponibles :
|
|
||||||
{{CONTEXTE_RAG}}`
|
|
||||||
|
|
||||||
// ── Handler ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export default defineEventHandler(async (event) => {
|
|
||||||
const config = useRuntimeConfig()
|
|
||||||
|
|
||||||
// 1. Rate limit
|
|
||||||
const ip =
|
|
||||||
getHeader(event, 'x-forwarded-for')?.split(',')[0].trim() ||
|
|
||||||
event.node.req.socket?.remoteAddress ||
|
|
||||||
'0.0.0.0'
|
|
||||||
|
|
||||||
const allowed = checkRateLimitJson(ip, 'chatbot-v2', 10)
|
|
||||||
if (!allowed) {
|
|
||||||
throw createError({
|
|
||||||
statusCode: 429,
|
|
||||||
statusMessage: 'Limite de 10 questions par jour atteinte.'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Validation body
|
|
||||||
const body = await readBody(event)
|
|
||||||
const question: string = (body?.question ?? '').trim()
|
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
|
||||||
throw createError({ statusCode: 400, statusMessage: 'Question trop courte.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
const mistralApiKey = config.mistralApiKey as string
|
|
||||||
if (!mistralApiKey) {
|
|
||||||
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Charger embeddings V2 (lazy, cachés en mémoire)
|
|
||||||
const embeddingsV2 = loadEmbeddingsV2()
|
|
||||||
|
|
||||||
// Graceful fallback si le script vectorize-v2.js n'a pas encore été lancé
|
|
||||||
if (embeddingsV2.length === 0) {
|
|
||||||
return {
|
|
||||||
reponse_texte: "La base vectorielle V2 est en cours de préparation. Merci d'utiliser le chatbot classique en attendant.",
|
|
||||||
fiches_recommandees: [],
|
|
||||||
sources: [],
|
|
||||||
v2_ready: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Embed la query via Mistral Embed
|
|
||||||
let queryEmbedding: number[]
|
|
||||||
try {
|
|
||||||
const embedRes = await $fetch<{ data: { embedding: number[] }[] }>(
|
|
||||||
'https://api.mistral.ai/v1/embeddings',
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'mistral-embed',
|
|
||||||
inputs: [question]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
)
|
|
||||||
queryEmbedding = embedRes.data[0].embedding
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[chatbot-v2] Erreur embedding Mistral :', e?.message ?? e)
|
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur embedding Mistral.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. Top-5 cosine similarity
|
|
||||||
const v2Results = topKSearch(embeddingsV2, queryEmbedding, 5)
|
|
||||||
|
|
||||||
// 6. Contexte RAG
|
|
||||||
const candidatesContext = v2Results.map(r => ({
|
|
||||||
fiche_id: r.fiche_id,
|
|
||||||
nom: r.nom,
|
|
||||||
famille: r.famille,
|
|
||||||
hashtags: r.hashtags,
|
|
||||||
score: r.score,
|
|
||||||
preview: r.text_preview
|
|
||||||
}))
|
|
||||||
|
|
||||||
const contextStr = candidatesContext
|
|
||||||
.map(c => `[${c.fiche_id}] ${c.nom} (famille ${c.famille}, score: ${c.score.toFixed(2)})\n${c.preview}`)
|
|
||||||
.join('\n\n---\n\n')
|
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT_V2.replace('{{CONTEXTE_RAG}}', contextStr)
|
|
||||||
|
|
||||||
// 7. Mistral Small - génération réponse
|
|
||||||
let mistralRaw: string
|
|
||||||
try {
|
|
||||||
const mistralRes = await $fetch<{
|
|
||||||
choices: { message: { content: string } }[]
|
|
||||||
}>('https://api.mistral.ai/v1/chat/completions', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${mistralApiKey}`,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: 'mistral-small-latest',
|
|
||||||
temperature: 0.3,
|
|
||||||
max_tokens: 600,
|
|
||||||
response_format: { type: 'json_object' },
|
|
||||||
messages: [
|
|
||||||
{ role: 'system', content: systemPrompt },
|
|
||||||
{ role: 'user', content: question }
|
|
||||||
]
|
|
||||||
})
|
|
||||||
})
|
|
||||||
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}'
|
|
||||||
} catch (e: any) {
|
|
||||||
console.error('[chatbot-v2] Erreur Mistral Small :', e?.message ?? e)
|
|
||||||
throw createError({ statusCode: 502, statusMessage: 'Erreur appel Mistral Small.' })
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Parse JSON
|
|
||||||
let parsed: { reponse_texte: string; fiches_recommandees: any[] }
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(mistralRaw)
|
|
||||||
if (!parsed.reponse_texte) throw new Error('reponse_texte absent')
|
|
||||||
} catch {
|
|
||||||
parsed = {
|
|
||||||
reponse_texte: "Impossible d'analyser la réponse.",
|
|
||||||
fiches_recommandees: []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
reponse_texte: parsed.reponse_texte,
|
|
||||||
fiches_recommandees: parsed.fiches_recommandees ?? [],
|
|
||||||
sources: candidatesContext,
|
|
||||||
v2_ready: true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
import { checkRateLimitJson } from '~/server/utils/rateLimitJson'
|
||||||
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
import { checkBudget, calcCoutMistralSmall } from '~/server/utils/circuitBreaker'
|
||||||
import { pickBifrostTier, bifrostContent, REASONING_MARGIN_TOKENS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
import { pickBifrostTier, profondeurDuBody, bifrostMaxTokens, bifrostDebug, bifrostContent, REGLE_GUILLEMETS, type BifrostChatResponse } from '~/server/utils/bifrost'
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
// ── Types ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -70,6 +70,32 @@ FORMAT DE SORTIE :
|
|||||||
CONTEXTE — Organisations disponibles dans la base NAV :
|
CONTEXTE — Organisations disponibles dans la base NAV :
|
||||||
{{FICHES_JSON}}`
|
{{FICHES_JSON}}`
|
||||||
|
|
||||||
|
// ── Prompt « réflexion » (mode approfondi, v1 — prompts définitifs par onglet : B7/B9) ──────
|
||||||
|
// Même format de sortie JSON que SYSTEM_PROMPT (le front en dépend) ; seule la posture change.
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT_REFLEXION = `Tu es un conseiller-ressources au service de la transition écologique des pratiques architecturales, sur AEP (Architecture d'Écologie Politique) — Écosystème Entraide, une base collaborative qui référence les acteurs de l'écologie politique appliquée à l'architecture et au territoire (organisations, outils, ressources) pour les architectes en France.
|
||||||
|
|
||||||
|
POSTURE : l'usager pose rarement le bon problème du premier coup. Tu l'aides d'abord à formuler son besoin, puis tu l'orientes vers les ressources et fiches du contexte. Tu es dialectique : tu reformules ce que tu comprends, tu nommes une tension ou un choix que la question laisse ouvert, tu poses 2 ou 3 questions qui l'aident à se situer (où en est son projet, avec qui, à quelle échelle, quelle contrainte pèse le plus), PUIS tu proposes des pistes.
|
||||||
|
|
||||||
|
RÈGLES ABSOLUES :
|
||||||
|
1. Tu ne peux recommander QUE des organisations présentes dans le contexte ci-dessous. Ne jamais inventer d'organisation absente.
|
||||||
|
2. Cite chaque organisation recommandée par son nom exact et son identifiant id. Si aucune n'est pertinente, dis-le honnêtement.
|
||||||
|
3. ${REGLE_GUILLEMETS}
|
||||||
|
4. Réponse de 300 mots maximum, en français, en prose (pas de liste à puces), ton de pair, tutoiement.
|
||||||
|
5. Si la question est hors du champ architecture / écologie / territoire / transition, recadre poliment vers le périmètre de la carte.
|
||||||
|
6. Retourne UNIQUEMENT un objet JSON valide, sans texte avant ou après.
|
||||||
|
|
||||||
|
FORMAT DE SORTIE :
|
||||||
|
{
|
||||||
|
"reponse_texte": "Ta réponse en prose (max 300 mots) : reformulation, tension, questions, puis pistes",
|
||||||
|
"fiches_recommandees": [
|
||||||
|
{ "id": 123, "explication": "Pourquoi cette fiche répond à la question (1-2 phrases max)" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
CONTEXTE — Organisations disponibles dans la base NAV :
|
||||||
|
{{FICHES_JSON}}`
|
||||||
|
|
||||||
// ── Recherche par mots-clés ────────────────────────────────────────────────────
|
// ── Recherche par mots-clés ────────────────────────────────────────────────────
|
||||||
|
|
||||||
function scoreOrg(org: OrgRow, keywords: string[]): number {
|
function scoreOrg(org: OrgRow, keywords: string[]): number {
|
||||||
@@ -182,7 +208,8 @@ export default defineEventHandler(async (event) => {
|
|||||||
const body = await readBody(event)
|
const body = await readBody(event)
|
||||||
const question: string = (body?.question ?? '').trim()
|
const question: string = (body?.question ?? '').trim()
|
||||||
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
const filters: { fonction?: string; echelle?: string } = body?.filters ?? {}
|
||||||
const tier = pickBifrostTier(body?.mode)
|
const profondeur = profondeurDuBody(body)
|
||||||
|
const tier = pickBifrostTier(profondeur)
|
||||||
|
|
||||||
if (!question || question.length < 3) {
|
if (!question || question.length < 3) {
|
||||||
throw createError({
|
throw createError({
|
||||||
@@ -245,7 +272,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
ville: o.localisation_ville ?? '',
|
ville: o.localisation_ville ?? '',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const systemPrompt = SYSTEM_PROMPT.replace(
|
const systemPrompt = (profondeur === 'approfondi' ? SYSTEM_PROMPT_REFLEXION : SYSTEM_PROMPT).replace(
|
||||||
'{{FICHES_JSON}}',
|
'{{FICHES_JSON}}',
|
||||||
JSON.stringify(fichesContext, null, 0),
|
JSON.stringify(fichesContext, null, 0),
|
||||||
)
|
)
|
||||||
@@ -265,6 +292,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
let tokensIn = 0
|
let tokensIn = 0
|
||||||
let tokensOut = 0
|
let tokensOut = 0
|
||||||
let realModel = tier.model
|
let realModel = tier.model
|
||||||
|
let debugExtra: Record<string, unknown> = {}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
|
||||||
@@ -277,7 +305,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
model: tier.model,
|
model: tier.model,
|
||||||
fallbacks: tier.fallbacks,
|
fallbacks: tier.fallbacks,
|
||||||
temperature: 0.3,
|
temperature: 0.3,
|
||||||
max_tokens: 600 + REASONING_MARGIN_TOKENS,
|
max_tokens: bifrostMaxTokens(600, profondeur),
|
||||||
response_format: { type: 'json_object' },
|
response_format: { type: 'json_object' },
|
||||||
messages: [
|
messages: [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
@@ -287,6 +315,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
mistralRaw = bifrostContent(bifrostRes, 'chatbot')
|
||||||
|
debugExtra = bifrostDebug(bifrostRes, 'chatbot', profondeur)
|
||||||
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
|
||||||
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
tokensOut = bifrostRes.usage?.completion_tokens ?? 0
|
||||||
realModel =
|
realModel =
|
||||||
@@ -340,5 +369,5 @@ export default defineEventHandler(async (event) => {
|
|||||||
coutEur,
|
coutEur,
|
||||||
})
|
})
|
||||||
|
|
||||||
return parsed
|
return { ...parsed, ...debugExtra }
|
||||||
})
|
})
|
||||||
|
|||||||
+43
-5
@@ -4,8 +4,9 @@
|
|||||||
* Auth : header x-bf-vk
|
* Auth : header x-bf-vk
|
||||||
*
|
*
|
||||||
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
|
* 2 tiers (Mission M3 du build Bifrost ; modèles primaires changés le 28/09, session AEP front 4) :
|
||||||
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
|
* RAPIDE — défaut (mode « Question » du toggle front)
|
||||||
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
|
* APPROFONDI — body.profondeur === 'approfondi' (mode « Réflexion plus profonde », B1-M3 ;
|
||||||
|
* body.mode reste lu en compat ascendante). Contrat : utils/chatProfondeur.ts
|
||||||
*
|
*
|
||||||
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
|
* ⚠ 28/09 : Groq a retiré groq/llama-3.1-8b-instant (RAPIDE) et groq/llama-3.3-70b-versatile
|
||||||
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
|
* (APPROFONDI) — 404 model_not_found via Bifrost. Depuis le 23/09 environ, chaque appel
|
||||||
@@ -32,6 +33,8 @@
|
|||||||
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { normaliserProfondeur, type ChatProfondeur } from '~/utils/chatProfondeur'
|
||||||
|
|
||||||
export const BIFROST_TIER_RAPIDE = {
|
export const BIFROST_TIER_RAPIDE = {
|
||||||
model: 'groq/openai/gpt-oss-20b',
|
model: 'groq/openai/gpt-oss-20b',
|
||||||
fallbacks: [
|
fallbacks: [
|
||||||
@@ -57,11 +60,34 @@ export const BIFROST_TIER_APPROFONDI = {
|
|||||||
*/
|
*/
|
||||||
export const REASONING_MARGIN_TOKENS = 800
|
export const REASONING_MARGIN_TOKENS = 800
|
||||||
|
|
||||||
/** Sélectionne le tier selon le param optionnel body.mode. */
|
/** Sélectionne le tier selon la profondeur ('approfondi' → APPROFONDI, tout le reste → RAPIDE). */
|
||||||
export function pickBifrostTier(mode?: string) {
|
export function pickBifrostTier(profondeur?: string) {
|
||||||
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
return profondeur === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profondeur demandée par le body : `profondeur`, sinon `mode` (compat ascendante des
|
||||||
|
* routes Bifrost avant B1-M3). Valeur inconnue → 'rapide'.
|
||||||
|
*/
|
||||||
|
export function profondeurDuBody(body: any): ChatProfondeur {
|
||||||
|
return normaliserProfondeur(body?.profondeur ?? body?.mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Budget max_tokens = contenu + marge de raisonnement. Le mode approfondi autorise une
|
||||||
|
* réponse un peu plus longue (+200) et une marge plus large (+400) : le 120b raisonne plus.
|
||||||
|
* Reste sous les limites par minute d'une clé Groq (prompt ~3-4 k + 2,2 k max).
|
||||||
|
*/
|
||||||
|
export function bifrostMaxTokens(contenu: number, profondeur: ChatProfondeur): number {
|
||||||
|
return profondeur === 'approfondi'
|
||||||
|
? contenu + 200 + REASONING_MARGIN_TOKENS + 400
|
||||||
|
: contenu + REASONING_MARGIN_TOKENS
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Règle guillemets, commune à tous les prompts « réflexion » (cf. REGLE_CITATIONS de Pensées). */
|
||||||
|
export const REGLE_GUILLEMETS =
|
||||||
|
'Ne mets entre guillemets que ce qui figure mot pour mot dans le contexte fourni ; sinon reformule sans guillemets.'
|
||||||
|
|
||||||
export interface BifrostChatResponse {
|
export interface BifrostChatResponse {
|
||||||
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
/** reasoning : présent avec gpt-oss, jamais lu (la réponse utile est dans content). */
|
||||||
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
choices: { message: { content: string; reasoning?: string }; finish_reason?: string }[]
|
||||||
@@ -69,6 +95,18 @@ export interface BifrostChatResponse {
|
|||||||
extra_fields?: { provider?: string; resolved_model_used?: string }
|
extra_fields?: { provider?: string; resolved_model_used?: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preuve du tier réellement servi : journal serveur toujours (modèle dans le log seulement),
|
||||||
|
* et champ `debug` dans la réponse UNIQUEMENT en dev (import.meta.dev, éliminé du build de
|
||||||
|
* production). Aucun nom de modèle n'est exposé publiquement.
|
||||||
|
*/
|
||||||
|
export function bifrostDebug(res: BifrostChatResponse, route: string, profondeur: ChatProfondeur) {
|
||||||
|
const modele = res.extra_fields?.resolved_model_used ?? null
|
||||||
|
const fournisseur = res.extra_fields?.provider ?? null
|
||||||
|
console.log(`[${route}] profondeur=${profondeur} fournisseur=${fournisseur} modèle=${modele}`)
|
||||||
|
return import.meta.dev ? { debug: { profondeur, fournisseur, modele } } : {}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
|
* Contenu JSON de la réponse. Une troncature (finish_reason 'length') ne lève aucune erreur
|
||||||
* et finit en message générique côté usager : on la signale au journal pour la voir.
|
* et finit en message générique côté usager : on la signale au journal pour la voir.
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
/**
|
||||||
|
* Garde-fou des citations (B1-M4) — fonctions pures, sans dépendance (réutilisables par les scripts Node).
|
||||||
|
*
|
||||||
|
* Problème : une consigne de prompt (REGLE_CITATIONS) ne suffit pas, le modèle met entre guillemets des
|
||||||
|
* phrases qui ne figurent pas dans le contexte récupéré (mesuré le 09/10 : 7/7 introuvables sur une
|
||||||
|
* question ouverte). Une fausse citation attribuée à un auteur réel engage le site : on la vérifie dans
|
||||||
|
* le code. Tout segment long entre guillemets absent des textes de chunks réellement récupérés perd ses
|
||||||
|
* guillemets (le texte reste, sans valeur de citation).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MIN_MOTS_CITATION = 6
|
||||||
|
|
||||||
|
/** Sans accents, minuscules, tout ce qui n'est ni lettre ni chiffre devient un espace (apostrophes, \r\n, markdown). */
|
||||||
|
export function normaliserTexte(s: string): string {
|
||||||
|
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SegmentCitation {
|
||||||
|
/** texte tel qu'il apparaît entre les guillemets */
|
||||||
|
segment: string
|
||||||
|
mots: number
|
||||||
|
/** true si segment court (< minMots) : jamais vérifié, jamais retiré */
|
||||||
|
court: boolean
|
||||||
|
/** true si retrouvé dans le contexte (toujours true pour un segment court) */
|
||||||
|
trouve: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultatCitations {
|
||||||
|
/** réponse dont les citations longues introuvables ont perdu leurs guillemets */
|
||||||
|
texte: string
|
||||||
|
gardees: number
|
||||||
|
retirees: number
|
||||||
|
segments: SegmentCitation[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// « … » (espaces fines ou insécables tolérés), “ … ”, " … ". Les « » sont traités en premier :
|
||||||
|
// un "…" imbriqué ne doit pas casser l'appariement.
|
||||||
|
const MOTIFS: RegExp[] = [
|
||||||
|
/«[\s ]*([^»]+?)[\s ]*»/g,
|
||||||
|
/“([^”]+?)”/g,
|
||||||
|
/"([^"\n]+?)"/g,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Élision dans une citation : [...], […], … ou ... — chaque morceau doit être retrouvé séparément.
|
||||||
|
const ELISION = /\[\s*(?:\.{3}|…)\s*\]|…|\.{3}/
|
||||||
|
|
||||||
|
function mots(n: string): number {
|
||||||
|
return n ? n.split(' ').length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vérifie les citations de `reponse` contre `contextes` (textes des chunks récupérés).
|
||||||
|
* - segment >= minMots mots : cherché (normalisé, borné aux mots entiers) ; absent => guillemets retirés.
|
||||||
|
* - segment < minMots mots (titres, expressions) : laissé tel quel.
|
||||||
|
* Aucun contexte exploitable => aucune citation longue ne peut être vérifiée, donc aucune n'est gardée.
|
||||||
|
*/
|
||||||
|
export function verifierCitations(reponse: string, contextes: string[], minMots: number = MIN_MOTS_CITATION): ResultatCitations {
|
||||||
|
const ctx = ` ${normaliserTexte(contextes.join('\n'))} `
|
||||||
|
const segments: SegmentCitation[] = []
|
||||||
|
let gardees = 0
|
||||||
|
let retirees = 0
|
||||||
|
|
||||||
|
const trouve = (seg: string): boolean => {
|
||||||
|
const parts = seg.split(ELISION).map(normaliserTexte).filter(Boolean)
|
||||||
|
return parts.length > 0 && parts.every(p => ctx.includes(` ${p} `))
|
||||||
|
}
|
||||||
|
|
||||||
|
let texte = reponse
|
||||||
|
for (const motif of MOTIFS) {
|
||||||
|
texte = texte.replace(motif, (entier: string, seg: string) => {
|
||||||
|
const n = normaliserTexte(seg.split(ELISION).join(' '))
|
||||||
|
const nb = mots(n)
|
||||||
|
if (nb < minMots) {
|
||||||
|
segments.push({ segment: seg, mots: nb, court: true, trouve: true })
|
||||||
|
return entier
|
||||||
|
}
|
||||||
|
const ok = trouve(seg)
|
||||||
|
segments.push({ segment: seg, mots: nb, court: false, trouve: ok })
|
||||||
|
if (ok) { gardees++; return entier }
|
||||||
|
retirees++
|
||||||
|
return seg
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return { texte, gardees, retirees, segments }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Concatène les textes de chunks d'une réponse LightRAG (`references[].content`, tableau de chaînes ou chaîne). */
|
||||||
|
export function contenusChunks(references: unknown): string[] {
|
||||||
|
if (!Array.isArray(references)) return []
|
||||||
|
const out: string[] = []
|
||||||
|
for (const r of references) {
|
||||||
|
const c = (r as { content?: unknown })?.content
|
||||||
|
if (Array.isArray(c)) for (const x of c) { if (typeof x === 'string') out.push(x) }
|
||||||
|
else if (typeof c === 'string') out.push(c)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
/**
|
|
||||||
* Recherche vectorielle sur les embeddings V2
|
|
||||||
* Cosine similarity + top-K
|
|
||||||
*
|
|
||||||
* Utilisé par : server/api/chatbot-v2.post.ts
|
|
||||||
* Données : server/data/embeddings-v2.json (généré par scripts/vectorize-v2.js)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync, existsSync } from 'fs'
|
|
||||||
import { fileURLToPath } from 'url'
|
|
||||||
import { resolve, dirname } from 'path'
|
|
||||||
|
|
||||||
// ── Types ──────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export interface EmbeddingEntry {
|
|
||||||
fiche_id: string
|
|
||||||
nom: string
|
|
||||||
famille: number
|
|
||||||
hashtags: string[]
|
|
||||||
embedding: number[]
|
|
||||||
text_preview: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
fiche_id: string
|
|
||||||
nom: string
|
|
||||||
famille: number
|
|
||||||
hashtags: string[]
|
|
||||||
score: number
|
|
||||||
text_preview: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Cosine similarity ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function cosineSimilarity(a: number[], b: number[]): number {
|
|
||||||
if (a.length !== b.length) return 0
|
|
||||||
let dot = 0, normA = 0, normB = 0
|
|
||||||
for (let i = 0; i < a.length; i++) {
|
|
||||||
dot += a[i] * b[i]
|
|
||||||
normA += a[i] * a[i]
|
|
||||||
normB += b[i] * b[i]
|
|
||||||
}
|
|
||||||
const denom = Math.sqrt(normA) * Math.sqrt(normB)
|
|
||||||
return denom === 0 ? 0 : dot / denom
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Top-K search ───────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export function topKSearch(
|
|
||||||
embeddings: EmbeddingEntry[],
|
|
||||||
queryEmbedding: number[],
|
|
||||||
k: number = 5
|
|
||||||
): SearchResult[] {
|
|
||||||
return embeddings
|
|
||||||
.map(e => ({
|
|
||||||
fiche_id: e.fiche_id,
|
|
||||||
nom: e.nom,
|
|
||||||
famille: e.famille,
|
|
||||||
hashtags: e.hashtags,
|
|
||||||
score: cosineSimilarity(e.embedding, queryEmbedding),
|
|
||||||
text_preview: e.text_preview
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.slice(0, k)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Chargement lazy des embeddings (cache module-level) ────────────────────────
|
|
||||||
|
|
||||||
let _embeddingsV2: EmbeddingEntry[] | null = null
|
|
||||||
|
|
||||||
export function loadEmbeddingsV2(): EmbeddingEntry[] {
|
|
||||||
if (_embeddingsV2 !== null) return _embeddingsV2
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Résolution du chemin depuis server/utils/ vers server/data/
|
|
||||||
const currentDir = dirname(fileURLToPath(import.meta.url))
|
|
||||||
const embPath = resolve(currentDir, '..', 'data', 'embeddings-v2.json')
|
|
||||||
|
|
||||||
if (!existsSync(embPath)) {
|
|
||||||
console.warn('[vectorSearch] embeddings-v2.json absent - V2 vector search désactivé')
|
|
||||||
console.warn('[vectorSearch] Lancer : MISTRAL_API_KEY=xxx node scripts/vectorize-v2.js')
|
|
||||||
_embeddingsV2 = []
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = readFileSync(embPath, 'utf-8')
|
|
||||||
const data = JSON.parse(raw)
|
|
||||||
_embeddingsV2 = data.embeddings ?? []
|
|
||||||
console.log(`[vectorSearch] ${_embeddingsV2!.length} embeddings V2 chargés (${data.meta?.model ?? 'unknown'})`)
|
|
||||||
return _embeddingsV2!
|
|
||||||
} catch (e: any) {
|
|
||||||
console.warn('[vectorSearch] Erreur chargement embeddings-v2.json :', e?.message ?? e)
|
|
||||||
_embeddingsV2 = []
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/**
|
||||||
|
* Mode de profondeur des chatbots (M3) — contrat partagé front/serveur.
|
||||||
|
* Champ de body : `profondeur` = 'rapide' | 'approfondi' (défaut 'rapide').
|
||||||
|
* Les libellés affichés vivent ICI, à un seul endroit : les changer ne touche ni
|
||||||
|
* le composant ni le serveur.
|
||||||
|
*/
|
||||||
|
export type ChatProfondeur = 'rapide' | 'approfondi'
|
||||||
|
|
||||||
|
export const CHAT_PROFONDEUR_DEFAUT: ChatProfondeur = 'rapide'
|
||||||
|
|
||||||
|
export const CHAT_PROFONDEUR_LIBELLES: Record<ChatProfondeur, string> = {
|
||||||
|
rapide: 'Question',
|
||||||
|
approfondi: 'Réflexion plus profonde',
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sous-texte (title/aria) de chaque option. */
|
||||||
|
export const CHAT_PROFONDEUR_AIDES: Record<ChatProfondeur, string> = {
|
||||||
|
rapide: 'Réponse directe et concise',
|
||||||
|
approfondi: 'Une réponse plus longue, qui questionne ton besoin avant de répondre',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHAT_PROFONDEURS = Object.keys(CHAT_PROFONDEUR_LIBELLES) as ChatProfondeur[]
|
||||||
|
|
||||||
|
/** Normalise une valeur inconnue (body, stockage) vers un mode valide. */
|
||||||
|
export function normaliserProfondeur(v: unknown): ChatProfondeur {
|
||||||
|
return v === 'approfondi' ? 'approfondi' : 'rapide'
|
||||||
|
}
|
||||||
+71
-1
@@ -121,7 +121,8 @@ export function lireQuery(query: Query, cfg: ConfigVueFiches): EtatFiches {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas
|
* Query à écrire pour un état. Les paramètres que la vue fiches ne gère pas
|
||||||
* (`territoire`, `random`, `fiche` de B12…) sont recopiés depuis `base`.
|
* (`territoire`, `random`…) sont recopiés depuis `base`, `fiche` aussi : un
|
||||||
|
* filtre changé garde la fiche ouverte (elle peut passer « hors de vos filtres »).
|
||||||
*/
|
*/
|
||||||
export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record<string, string> {
|
export function ecrireQuery(etat: EtatFiches, cfg: ConfigVueFiches, base: Query = {}): Record<string, string> {
|
||||||
const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)])
|
const geres = new Set(['vue', 'q', 'adresse', 'tri', 'groupe', ...cfg.dimensions.map((d) => d.cle)])
|
||||||
@@ -295,3 +296,72 @@ export function regrouper(fiches: MiniFiche[], def: GroupeDef | null, cfg: Confi
|
|||||||
export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] {
|
export function ordreFiches(groupes: GroupeRendu[]): MiniFiche[] {
|
||||||
return groupes.flatMap((g) => g.fiches)
|
return groupes.flatMap((g) => g.fiches)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Fiche ouverte (B12) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** `fiche=` de l'URL ; null si absent ou vide. L'id reste une chaîne (Entraide : nombre, Réseaux : `f1-rotor`). */
|
||||||
|
export function lireFiche(query: Query): string | null {
|
||||||
|
const v = premiere(query.fiche).trim()
|
||||||
|
return v || null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Query avec `fiche=` posé (id) ou retiré (null), tout le reste recopié tel quel. */
|
||||||
|
export function queryAvecFiche(query: Query, id: string | number | null): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {}
|
||||||
|
for (const [k, v] of Object.entries(query)) {
|
||||||
|
if (k !== 'fiche' && premiere(v) !== '') out[k] = premiere(v)
|
||||||
|
}
|
||||||
|
if (id != null && String(id) !== '') out.fiche = String(id)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PositionFiche {
|
||||||
|
/** Rang dans l'ordre courant, de 1 à total. */
|
||||||
|
rang: number
|
||||||
|
total: number
|
||||||
|
precedente: MiniFiche | null
|
||||||
|
suivante: MiniFiche | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Place de la fiche dans l'ordre courant (filtres + tri + regroupement).
|
||||||
|
* null = fiche hors de l'ordre courant (« hors de vos filtres ») : pas de
|
||||||
|
* précédente ni de suivante. Pas de bouclage aux extrémités.
|
||||||
|
*/
|
||||||
|
export function positionFiche(ordre: MiniFiche[], id: string | number | null): PositionFiche | null {
|
||||||
|
if (id == null) return null
|
||||||
|
const i = ordre.findIndex((f) => String(f.id) === String(id))
|
||||||
|
if (i === -1) return null
|
||||||
|
return {
|
||||||
|
rang: i + 1,
|
||||||
|
total: ordre.length,
|
||||||
|
precedente: i > 0 ? ordre[i - 1] : null,
|
||||||
|
suivante: i < ordre.length - 1 ? ordre[i + 1] : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Historique (SPEC §5) : OUVRIR une fiche depuis la liste ajoute une entrée
|
||||||
|
* (le retour du téléphone la referme) ; passer d'une fiche à l'autre la
|
||||||
|
* remplace (le retour ne rejoue pas les vingt fiches parcourues).
|
||||||
|
*/
|
||||||
|
export function modeOuverture(ficheCourante: string | null): 'push' | 'replace' {
|
||||||
|
return ficheCourante == null ? 'push' : 'replace'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FERMER : si l'entrée a été ajoutée par cette page ET que la liste derrière
|
||||||
|
* n'a pas changé depuis (mêmes filtres, tri, vue), on revient en arrière :
|
||||||
|
* l'historique reste propre. Sinon (lien partagé, rechargement, filtre changé
|
||||||
|
* fiche ouverte), on retire `fiche=` sur place : un retour arrière aurait
|
||||||
|
* défait les filtres choisis entre-temps.
|
||||||
|
*/
|
||||||
|
export function modeFermeture(ouverteParLaPage: boolean, listeInchangee: boolean): 'back' | 'replace' {
|
||||||
|
return ouverteParLaPage && listeInchangee ? 'back' : 'replace'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Empreinte de la liste derrière la fiche : la query sans `fiche=`, clés triées. */
|
||||||
|
export function empreinteListe(query: Query): string {
|
||||||
|
const q = queryAvecFiche(query, null)
|
||||||
|
return Object.keys(q).sort().map((k) => `${k}=${q[k]}`).join('&')
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user