Compare commits

..
4 Commits
Author SHA1 Message Date
JulesandClaude Opus 5.5 b5aca80bbc fix(carte): fond de carte avec clé CARTO, repli Plan IGN sans clé
CARTO Basemaps exige une clé depuis le 23/09/2026 : sans elle, chaque tuile
porte le filigrane « API KEY REQUIRED ». Clé lue au runtime
(NUXT_PUBLIC_CARTO_KEY) ; sans clé, repli sur le Plan IGN (Géoplateforme).
FicheDetail quitte aussi les tuiles OSM (418, politique d'usage).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 12:00:49 +02:00
Jules Neny 946ed53aba feat(outils): note détaillée annuaire fmhy — cartographie traduite par usage, avertissement légal, snapshot 2026-08-25 2026-08-25 19:03:16 +02:00
Jules Neny d06c09a476 fix(bifrost): retire cerebras/gemma-4-31b du fallback tier RAPIDE (JSON invalide ~75% du temps) 2026-07-15 22:53:54 +02:00
Jules NenyandClaude Sonnet 5 4291fe7529 feat(chatbot): branche Entraide/Réseaux/Jobs sur Bifrost au lieu de Mistral direct
Les 3 routes chatbot (chatbot.post.ts, chatbot-reseaux.post.ts, chatbot-taff.post.ts)
appellent désormais ${bifrostUrl}/v1/chat/completions (header x-bf-vk) au lieu de
api.mistral.ai direct. Tier RAPIDE par défaut (groq/llama-3.1-8b-instant + fallbacks
cerebras/gemini-flash-lite/cohere), tier APPROFONDI si body.mode === 'approfondi'
(prêt pour un futur toggle UI, hors-scope ici).

- server/utils/bifrost.ts (nouveau) : mutualise les 2 tiers pour les 3 routes.
- nuxt.config.ts : ajoute bifrostUrl/bifrostVk au runtimeConfig (mistralApiKey
  conservé, juste plus utilisé par ces 3 routes).
- chatbot.post.ts : garde son circuit breaker + logging stats_usage tels quels,
  mais logUsage reflète maintenant le provider/modèle réel ayant répondu
  (extra_fields de Bifrost) et ne calcule un coût que si ce provider est Mistral
  (les autres tiers Bifrost sont free-tier — cout_eur=0 sinon, pour ne pas fausser
  le circuit breaker budget).
- chatbot-reseaux.post.ts / chatbot-taff.post.ts : aucun circuit breaker/logging
  avant, aucun ajouté (asymétrie pré-existante préservée telle quelle).
- chatbot-v2.post.ts (orphelin) et chatbot-pensees.post.ts (proxy LightRAG,
  config runtime séparée) non touchés.

Testé en dev local contre Bifrost (IP Tailscale) : 1 appel réel par route,
réponses conformes, provider réel confirmé dans les logs de test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 22:39:16 +02:00
11 changed files with 407 additions and 55 deletions
+2 -1
View File
@@ -183,6 +183,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
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 }>()
@@ -283,7 +284,7 @@ onMounted(async () => {
attributionControl: false, attributionControl: false,
}) })
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer(baseTiles.url(document.documentElement.classList.contains('dark')), {
maxZoom: 18, maxZoom: 18,
}).addTo(map) }).addTo(map)
+4 -7
View File
@@ -5,6 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet' import type { Map, Marker, DivIcon } from 'leaflet'
interface Org { interface Org {
@@ -90,12 +91,10 @@ async function initMap() {
// Fond de carte CartoDB Positron (light ou dark selon theme) // Fond de carte CartoDB Positron (light ou dark selon theme)
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark const tileUrl = baseTiles.url(isDark)
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, { tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', attribution: baseTiles.attribution,
maxZoom: 19, maxZoom: 19,
}) })
tileLayerInstance.addTo(mapInstance!) tileLayerInstance.addTo(mapInstance!)
@@ -212,9 +211,7 @@ function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L const L = (window as any).L
if (!L) return if (!L) return
const url = dark const url = baseTiles.url(dark)
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url) tileLayerInstance.setUrl(url)
} }
+4 -7
View File
@@ -5,6 +5,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map, Marker, DivIcon } from 'leaflet' import type { Map, Marker, DivIcon } from 'leaflet'
import type { StructureV2 } from '~/types/structure-v2' import type { StructureV2 } from '~/types/structure-v2'
@@ -87,12 +88,10 @@ async function initMap() {
}) })
const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
const tileUrl = isDark const tileUrl = baseTiles.url(isDark)
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance = L.tileLayer(tileUrl, { tileLayerInstance = L.tileLayer(tileUrl, {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>', attribution: baseTiles.attribution,
maxZoom: 19, maxZoom: 19,
}) })
tileLayerInstance.addTo(mapInstance!) tileLayerInstance.addTo(mapInstance!)
@@ -213,9 +212,7 @@ function updateTileTheme(dark: boolean) {
if (!mapInstance || !tileLayerInstance) return if (!mapInstance || !tileLayerInstance) return
const L = (window as any).L const L = (window as any).L
if (!L) return if (!L) return
const url = dark const url = baseTiles.url(dark)
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
tileLayerInstance.setUrl(url) tileLayerInstance.setUrl(url)
} }
+3 -4
View File
@@ -38,6 +38,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const baseTiles = useBaseTiles()
import type { Map as LeafletMap, TileLayer } from 'leaflet' import type { Map as LeafletMap, TileLayer } from 'leaflet'
interface Org { interface Org {
@@ -111,9 +112,7 @@ function createPinIcon(L: any, isPrioritaire: boolean, isSelected = false) {
} }
function getTileUrl(dark: boolean) { function getTileUrl(dark: boolean) {
return dark return baseTiles.url(dark)
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'
} }
async function initSingleMap(domName: string) { async function initSingleMap(domName: string) {
@@ -132,7 +131,7 @@ async function initSingleMap(domName: string) {
touchZoom: true, keyboard: false, touchZoom: true, keyboard: false,
}) })
const tileLayer = L.tileLayer(getTileUrl(isDark), { const tileLayer = L.tileLayer(getTileUrl(isDark), {
attribution: '© OpenStreetMap contributors © CARTO', maxZoom: 19, attribution: baseTiles.attribution, maxZoom: 19,
}) })
tileLayer.addTo(map) tileLayer.addTo(map)
tileLayers[domName] = tileLayer as unknown as TileLayer tileLayers[domName] = tileLayer as unknown as TileLayer
+22
View File
@@ -0,0 +1,22 @@
// Fond de carte commun aux cartes Leaflet.
// CARTO exige une clé depuis le 23/09/2026 (sans clé : filigrane « API KEY REQUIRED »).
// Clé fournie au runtime par NUXT_PUBLIC_CARTO_KEY ; sans clé, repli sur le Plan IGN (Géoplateforme, sans clé).
const IGN_URL = 'https://data.geopf.fr/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0'
+ '&LAYER=GEOGRAPHICALGRIDSYSTEMS.PLANIGNV2&STYLE=normal&TILEMATRIXSET=PM_0_19&FORMAT=image/png'
+ '&TILEMATRIX={z}&TILEROW={y}&TILECOL={x}'
export function useBaseTiles() {
const key = useRuntimeConfig().public.cartoKey as string
function url(dark: boolean) {
if (!key) return IGN_URL
const style = dark ? 'dark_all' : 'light_all'
return `https://{s}.basemaps.cartocdn.com/${style}/{z}/{x}/{y}{r}.png?key=${encodeURIComponent(key)}`
}
const attribution = key
? '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
: '© <a href="https://www.ign.fr/">IGN</a> – Géoplateforme'
return { url, attribution }
}
+5
View File
@@ -16,6 +16,8 @@ export default defineNuxtConfig({
commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID, commentTableId: process.env.COMMENT_TABLE_ID || process.env.AVIS_TABLE_ID,
statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc', statsTableId: process.env.STATS_TABLE_ID || 'mbbq7n47ixy19mc',
mistralApiKey: process.env.MISTRAL_API_KEY, mistralApiKey: process.env.MISTRAL_API_KEY,
bifrostUrl: process.env.BIFROST_URL || 'http://127.0.0.1:8080',
bifrostVk: process.env.BIFROST_VK,
redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisUrl: process.env.REDIS_URL || 'redis://127.0.0.1:6379',
resendApiKey: process.env.RESEND_API_KEY, resendApiKey: process.env.RESEND_API_KEY,
emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr', emailJules: process.env.EMAIL_JULES || 'jules@trans-former.fr',
@@ -25,6 +27,9 @@ export default defineNuxtConfig({
codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD codevAdminPassword: 'admin2026', // NUXT_CODEV_ADMIN_PASSWORD
ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621', ragPeUrl: process.env.RAG_PE_URL || 'http://localhost:9621',
referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '', referencesTableId: process.env.REFERENCES_TABLE_ID || process.env.NUXT_REFERENCES_TABLE_ID || '',
public: {
cartoKey: '', // NUXT_PUBLIC_CARTO_KEY — clé CARTO Basemaps (gratuite), lue au runtime
},
}, },
// Leaflet ne fonctionne pas en SSR — forcer le rendu côté client // Leaflet ne fonctionne pas en SSR — forcer le rendu côté client
+268 -5
View File
@@ -137,15 +137,55 @@
<!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ --> <!-- ══════════════ SECTION 4 — FMHY (cœur de la page) ══════════════ -->
<section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy"> <section class="outils-section outils-section--fmhy" aria-labelledby="sec-fmhy">
<h2 id="sec-fmhy" class="outils-section__title"> <h2 id="sec-fmhy" class="outils-section__title">
<span aria-hidden="true">🌳</span> Bibliothèque de ressources libres <span aria-hidden="true">🌳</span> Annuaire du web libre — la cartographie FMHY
</h2> </h2>
<p class="outils-section__desc"> <p class="outils-section__desc">
Le vrai trésor de l'onglet Outils. FMHY (Free Media Heck Yeah) est la plus grosse FMHY (FreeMediaHeckYeah) est le plus grand index communautaire de ressources
base communautaire d'outils, services et ressources libres/gratuits du web. J'en ai gratuites du web : des milliers de liens classés et vérifiés par des centaines
curé ~50 entrées pertinentes pour un architecte : IA, lecture, dev, vie privée, de contributeurs bénévoles, mis à jour en continu. C'est une forme de
formation, médias. Clique sur les branches pour explorer. résistance numérique — accéder librement au savoir, à la culture et aux
outils, hors des catalogues marchands. J'en ai traduit et organisé les
entrées phares <strong>par usage</strong>, pour aller droit au but.
</p> </p>
<!-- Note détaillée par usage -->
<div v-for="g in fmhyGuide" :key="g.id" class="fmhy-usage">
<h3 class="fmhy-usage__title">{{ g.titre }}</h3>
<p v-if="g.note" class="fmhy-usage__note">{{ g.note }}</p>
<ul class="fmhy-usage__list">
<li v-for="l in g.liens" :key="l.url" class="fmhy-usage__item">
<a
:href="l.url"
target="_blank"
rel="noopener noreferrer"
class="fmhy-usage__link"
>{{ l.nom }}</a>
<span class="fmhy-usage__desc">— {{ l.desc }}</span>
</li>
</ul>
</div>
<!-- Avertissement légal -->
<div class="fmhy-warning">
<h3 class="fmhy-warning__title">Avertissement</h3>
<p class="fmhy-warning__text">
Une partie des ressources indexées par FMHY relève du partage de contenus
protégés : la légalité dépend de ton pays et de l'usage que tu en fais.
Renseigne-toi avant d'utiliser ces liens. FMHY maintient une
<a href="https://fmhy.net/unsafe" target="_blank" rel="noopener noreferrer">liste des sites dangereux</a>
(malwares, arnaques) : consulte-la. En cas de doute, privilégie les sources
libres de droits (Project Gutenberg, Gallica, domaine public).
</p>
</div>
<p class="fmhy-snapshot">
Cartographie établie d'après un snapshot du 25 août 2026 — l'index fmhy
évolue en continu, voir
<a href="https://fmhy.net/" target="_blank" rel="noopener noreferrer">fmhy.net</a>.
</p>
<!-- Arbre curé interactif (sélection AEP) -->
<p class="fmhy-tree-label">Sélection interactive — clique sur les branches pour déplier</p>
<div class="fmhy-tree-wrap"> <div class="fmhy-tree-wrap">
<div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…"> <div v-if="fmhyPending" class="fmhy-loading" aria-label="Chargement…">
<span>Chargement…</span> <span>Chargement…</span>
@@ -188,6 +228,124 @@
const { data: outils } = await useFetch('/data/outils.json') const { data: outils } = await useFetch('/data/outils.json')
const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.json') const { data: fmhyData, pending: fmhyPending, error: fmhyError } = await useFetch('/data/fmhy-curated.json')
// Note détaillée FMHY — cartographie traduite et organisée par usage
// (entrées ★ « starred » de fmhy.net, snapshot du 25 août 2026)
const fmhyGuide = [
{
id: 'vie-privee',
titre: 'Vie privée & adblocking — les bases',
note: 'Le premier réflexe du guide débutant fmhy : un bon bloqueur de pubs rend la moitié du web à nouveau respirable.',
liens: [
{ nom: 'uBlock Origin', url: 'https://github.com/gorhill/uBlock', desc: 'bloqueur de pubs de référence, léger et open source' },
{ nom: 'SponsorBlock', url: 'https://sponsor.ajay.app/', desc: 'saute automatiquement les segments sponsorisés de YouTube' },
{ nom: 'LibreWolf', url: 'https://librewolf.net/', desc: 'Firefox durci vie privée, pré-configuré' },
{ nom: 'Proton VPN', url: 'https://protonvpn.com', desc: 'VPN suisse, offre gratuite illimitée' },
{ nom: 'Bitwarden', url: 'https://bitwarden.com/', desc: 'gestionnaire de mots de passe open source' },
{ nom: 'Cryptomator', url: 'https://cryptomator.org/', desc: 'chiffrement de tes fichiers avant envoi dans le cloud' },
{ nom: 'Tor Browser', url: 'https://www.torproject.org/', desc: 'navigation anonyme via le réseau Tor' },
{ nom: 'KeePassXC', url: 'https://keepassxc.org/', desc: 'coffre de mots de passe 100% local' }
]
},
{
id: 'livres',
titre: 'Trouver un livre / ebook',
liens: [
{ nom: "Anna's Archive", url: 'https://annas-archive.gl/', desc: 'la plus grosse bibliothèque du web : livres, BD, articles, thèses' },
{ nom: 'Z-Library', url: 'https://z-lib.gd/', desc: 'catalogue immense, interface soignée' },
{ nom: 'Library Genesis', url: 'https://libgen.li/', desc: 'référence pour les livres techniques et académiques' },
{ nom: 'Mobilism', url: 'https://forum.mobilism.org', desc: 'ebooks, audiobooks, magazines (forum)' },
{ nom: 'Project Gutenberg', url: 'https://www.gutenberg.org/', desc: '70 000+ classiques du domaine public, 100% légal' },
{ nom: 'Sci-Hub', url: 'https://sci-hub.se/', desc: 'accès libre aux articles scientifiques derrière paywall' },
{ nom: 'Calibre', url: 'https://calibre-ebook.com/', desc: 'gestion et conversion de bibliothèque numérique' },
{ nom: 'KOReader', url: 'https://koreader.rocks/', desc: 'lecteur ebook open source pour liseuses' }
]
},
{
id: 'films',
titre: 'Films, séries & anime',
liens: [
{ nom: 'Rive', url: 'https://www.rivestream.app/', desc: 'streaming films / séries / anime, auto-next' },
{ nom: 'Cineby', url: 'https://cineby.at/', desc: 'streaming films / séries / anime' },
{ nom: 'Flixer', url: 'https://flixer.gd', desc: 'streaming films / séries / anime' },
{ nom: 'Tubi', url: 'https://tubitv.com', desc: 'streaming légal et gratuit (financé par la pub)' },
{ nom: 'Stremio', url: 'https://www.stremio.com/', desc: 'centre multimédia extensible par extensions' },
{ nom: 'Miruro', url: 'https://www.miruro.com/', desc: 'anime en streaming' },
{ nom: 'Simkl', url: 'https://simkl.com/', desc: 'suivi de films, séries et anime déjà vus' }
]
},
{
id: 'musique',
titre: 'Musique & podcasts',
liens: [
{ nom: 'Monochrome', url: 'https://monochrome.tf/', desc: 'streaming musique dans le navigateur' },
{ nom: 'lucida', url: 'https://lucida.to/', desc: 'téléchargement de musiques' },
{ nom: 'SpotX', url: 'https://github.com/SpotX-Official/SpotX', desc: 'Spotify desktop sans pub (patch client)' },
{ nom: 'NTS Radio', url: 'https://www.nts.live/', desc: 'shows de DJ indépendants, archives immenses' },
{ nom: 'SomaFM', url: 'https://somafm.com/', desc: '30+ radios internet indépendantes' },
{ nom: 'Radio Garden', url: 'https://radio.garden/', desc: 'explorer les radios du monde entier sur un globe' },
{ nom: 'Pocket Casts', url: 'https://play.pocketcasts.com/', desc: 'lecteur de podcasts libre et complet, aussi dans le navigateur' }
]
},
{
id: 'logiciels',
titre: 'Logiciels & jeux vidéo',
liens: [
{ nom: 'qBittorrent', url: 'https://www.qbittorrent.org/', desc: 'client torrent libre, sans pub — la référence' },
{ nom: 'FitGirl Repacks', url: 'https://fitgirl-repacks.site/', desc: 'repacks de jeux ultra-compressés' },
{ nom: 'SteamRIP', url: 'https://steamrip.com/', desc: 'jeux pré-installés' },
{ nom: '1337x', url: 'https://1337x.to/', desc: 'annuaire torrents généraliste' }
]
},
{
id: 'mobile',
titre: 'Android / iOS',
liens: [
{ nom: 'Aurora Store', url: 'https://auroraoss.com/', desc: 'Google Play alternatif, sans compte Google' },
{ nom: 'Droid-ify', url: 'https://droidify.app/', desc: 'client F-Droid moderne — applications libres' },
{ nom: 'APKMirror', url: 'https://www.apkmirror.com/', desc: 'archives APK vérifiées' },
{ nom: 'Fossify', url: 'https://github.com/FossifyOrg', desc: 'suite d’apps Android simples et libres (galerie, agenda, fichiers…)' },
{ nom: 'Obtainium', url: 'https://obtainium.imranr.dev/', desc: 'installe et met à jour tes APKs depuis les sources officielles' }
]
},
{
id: 'education',
titre: 'Éducation, cours & recherche',
liens: [
{ nom: 'MIT OpenCourseWare', url: 'https://ocw.mit.edu/', desc: 'les cours du MIT en accès libre complet' },
{ nom: 'edX', url: 'https://www.edx.org/', desc: 'MOOCs de grandes universités' },
{ nom: 'Khan Academy', url: 'https://www.khanacademy.org/', desc: 'maths, sciences, économie — gratuit, du collège au supérieur' },
{ nom: 'Class Central', url: 'https://www.classcentral.com/', desc: 'moteur de recherche de cours en ligne' },
{ nom: 'WolframAlpha', url: 'https://www.wolframalpha.com/', desc: 'moteur de calcul et base de connaissances' },
{ nom: 'PhET', url: 'https://phet.colorado.edu/', desc: 'simulations interactives physique / chimie / maths' }
]
},
{
id: 'ia',
titre: 'Intelligence artificielle gratuite',
liens: [
{ nom: 'DeepSeek', url: 'https://chat.deepseek.com/', desc: 'modèle de pointe, usage illimité gratuit' },
{ nom: 'Qwen Studio', url: 'https://chat.qwen.ai/', desc: 'chat IA (Qwen) illimité gratuit' },
{ nom: 'Google AI Studio', url: 'https://aistudio.google.com/', desc: 'Gemini en accès généreux gratuit' },
{ nom: 'Mistral Le Chat', url: 'https://chat.mistral.ai/', desc: 'LLM français, souverain, gratuit' },
{ nom: 'LM Studio', url: 'https://lmstudio.ai/', desc: 'faire tourner des modèles en local, sans cloud' },
{ nom: 'Jan', url: 'https://jan.ai/', desc: 'application desktop libre pour IA locale' },
{ nom: 'Open WebUI', url: 'https://openwebui.com/', desc: 'interface web auto-hébergeable pour modèles locaux' }
]
},
{
id: 'francophone',
titre: 'Contenus francophones',
liens: [
{ nom: 'Gallica', url: 'https://gallica.bnf.fr/', desc: 'millions de documents numérisés de la BnF, domaine public' },
{ nom: 'Nos Livres', url: 'https://noslivres.net/', desc: 'classiques français du domaine public, formats propres' },
{ nom: 'Audiocite', url: 'https://www.audiocite.net/', desc: 'livres audio francophones du domaine public' },
{ nom: 'Littérature audio', url: 'https://www.litteratureaudio.com/', desc: 'autre grande bibliothèque audio francophone libre' },
{ nom: 'zone-ebook', url: 'https://zone-ebook.com/', desc: 'ebooks, magazines et journaux en français' },
{ nom: 'Dealabs', url: 'https://www.dealabs.com/', desc: 'bons plans et gratuités débusqués par la communauté' }
]
}
]
useSeoMeta({ useSeoMeta({
title: 'Outils — AEP', title: 'Outils — AEP',
description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.' description: 'Outils partagés entre architectes : simulateurs, open source, ressources libres FMHY, bifurcation post-études.'
@@ -526,6 +684,111 @@ useSeoMeta({
margin: 0 -0.5rem; margin: 0 -0.5rem;
} }
/* ── Note FMHY détaillée (par usage) ────────────────────────── */
.fmhy-usage {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.fmhy-usage__title {
font-size: 0.92rem;
font-weight: 650;
color: var(--nav-text);
margin: 0;
}
.fmhy-usage__note {
font-size: 0.8rem;
color: var(--nav-text-muted);
font-style: italic;
margin: 0;
line-height: 1.5;
}
.fmhy-usage__list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.fmhy-usage__item {
font-size: 0.84rem;
line-height: 1.5;
padding: 2px 6px;
border-radius: 5px;
}
.fmhy-usage__item:hover {
background: var(--nav-surface);
}
.fmhy-usage__link {
color: var(--nav-primary-solid);
font-weight: 500;
text-decoration: none;
}
.fmhy-usage__link:hover {
text-decoration: underline;
}
.fmhy-usage__desc {
color: var(--nav-text-muted);
}
.fmhy-warning {
background: var(--nav-surface);
border: 1px solid var(--nav-bg-alt);
border-left: 3px solid var(--nav-primary-solid);
border-radius: 8px;
padding: 0.875rem 1rem;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.fmhy-warning__title {
font-size: 0.85rem;
font-weight: 650;
color: var(--nav-text);
margin: 0;
}
.fmhy-warning__text {
font-size: 0.82rem;
color: var(--nav-text-muted);
line-height: 1.6;
margin: 0;
}
.fmhy-warning__text a {
color: var(--nav-primary-solid);
}
.fmhy-snapshot {
font-size: 0.78rem;
color: var(--nav-text-muted);
font-style: italic;
margin: 0;
}
.fmhy-snapshot a {
color: var(--nav-primary-solid);
}
.fmhy-tree-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--nav-text-muted);
margin: 0.5rem 0 -0.5rem;
}
.fmhy-tree-wrap { .fmhy-tree-wrap {
padding: 0.875rem 0.75rem; padding: 0.875rem 0.75rem;
} }
+10 -6
View File
@@ -6,6 +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, type BifrostChatResponse } from '~/server/utils/bifrost'
interface Structure { interface Structure {
id: string id: string
@@ -61,6 +62,7 @@ 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 structures: Structure[] = ((reseauxData as any).structures ?? []) const structures: Structure[] = ((reseauxData as any).structures ?? [])
const keywords = extractKeywords(question) const keywords = extractKeywords(question)
@@ -82,18 +84,20 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0)) const systemPrompt = SYSTEM_PROMPT.replace('{{STRUCTURES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string const bifrostUrl = config.bifrostUrl as string
if (!mistralApiKey) throw createError({ statusCode: 500, message: 'Clé API Mistral manquante.' }) const bifrostVk = config.bifrostVk as string
if (!bifrostVk) throw createError({ statusCode: 500, message: 'Clé Bifrost manquante.' })
let mistralRaw: string let mistralRaw: string
try { try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>( const res = await $fetch<BifrostChatResponse>(
'https://api.mistral.ai/v1/chat/completions', `${bifrostUrl}/v1/chat/completions`,
{ {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: 700, max_tokens: 700,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
+11 -7
View File
@@ -7,6 +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, type BifrostChatResponse } from '~/server/utils/bifrost'
interface PlateformeMinimal { interface PlateformeMinimal {
id: string id: string
@@ -66,6 +67,7 @@ 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)
// 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,20 +93,22 @@ export default defineEventHandler(async (event) => {
const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0)) const systemPrompt = SYSTEM_PROMPT.replace('{{PLATEFORMES_JSON}}', JSON.stringify(context, null, 0))
const mistralApiKey = config.mistralApiKey as string const bifrostUrl = config.bifrostUrl as string
if (!mistralApiKey) { const bifrostVk = config.bifrostVk as string
throw createError({ statusCode: 500, statusMessage: 'Clé API Mistral manquante.' }) if (!bifrostVk) {
throw createError({ statusCode: 500, statusMessage: 'Clé Bifrost manquante.' })
} }
let mistralRaw: string let mistralRaw: string
try { try {
const res = await $fetch<{ choices: { message: { content: string } }[] }>( const res = await $fetch<BifrostChatResponse>(
'https://api.mistral.ai/v1/chat/completions', `${bifrostUrl}/v1/chat/completions`,
{ {
method: 'POST', method: 'POST',
headers: { Authorization: `Bearer ${mistralApiKey}`, 'Content-Type': 'application/json' }, headers: { 'x-bf-vk': bifrostVk, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: 700, max_tokens: 700,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
+30 -17
View File
@@ -19,6 +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, type BifrostChatResponse } from '~/server/utils/bifrost'
// ── Types ────────────────────────────────────────────────────────────────────── // ── Types ──────────────────────────────────────────────────────────────────────
@@ -131,18 +132,19 @@ async function logUsage(params: {
nocodbUrl: string nocodbUrl: string
nocodbToken: string nocodbToken: string
statsTableId: string statsTableId: string
model: string
tokensIn: number tokensIn: number
tokensOut: number tokensOut: number
coutEur: number coutEur: number
}) { }) {
const { nocodbUrl, nocodbToken, statsTableId, tokensIn, tokensOut, coutEur } = params const { nocodbUrl, nocodbToken, statsTableId, model, tokensIn, tokensOut, coutEur } = params
const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records` const logUrl = `${nocodbUrl}/api/v2/tables/${statsTableId}/records`
try { try {
await $fetch(logUrl, { await $fetch(logUrl, {
method: 'POST', method: 'POST',
headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' }, headers: { 'xc-token': nocodbToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model,
endpoint: 'chatbot', endpoint: 'chatbot',
tokens_in: tokensIn, tokens_in: tokensIn,
tokens_out: tokensOut, tokens_out: tokensOut,
@@ -180,6 +182,7 @@ 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)
if (!question || question.length < 3) { if (!question || question.length < 3) {
throw createError({ throw createError({
@@ -247,32 +250,32 @@ export default defineEventHandler(async (event) => {
JSON.stringify(fichesContext, null, 0), JSON.stringify(fichesContext, null, 0),
) )
// 6. Appel Mistral Small // 6. Appel Bifrost (gateway LLM — remplace l'appel Mistral direct)
const mistralApiKey = config.mistralApiKey as string const bifrostUrl = config.bifrostUrl as string
const bifrostVk = config.bifrostVk as string
if (!mistralApiKey) { if (!bifrostVk) {
throw createError({ throw createError({
statusCode: 500, statusCode: 500,
statusMessage: 'Clé API Mistral manquante.', statusMessage: 'Clé Bifrost manquante.',
}) })
} }
let mistralRaw: string let mistralRaw: string
let tokensIn = 0 let tokensIn = 0
let tokensOut = 0 let tokensOut = 0
let realModel = tier.model
try { try {
const mistralRes = await $fetch<{ const bifrostRes = await $fetch<BifrostChatResponse>(`${bifrostUrl}/v1/chat/completions`, {
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
}>('https://api.mistral.ai/v1/chat/completions', {
method: 'POST', method: 'POST',
headers: { headers: {
Authorization: `Bearer ${mistralApiKey}`, 'x-bf-vk': bifrostVk,
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
model: 'mistral-small-latest', model: tier.model,
fallbacks: tier.fallbacks,
temperature: 0.3, temperature: 0.3,
max_tokens: 600, max_tokens: 600,
response_format: { type: 'json_object' }, response_format: { type: 'json_object' },
@@ -283,11 +286,16 @@ export default defineEventHandler(async (event) => {
}), }),
}) })
mistralRaw = mistralRes.choices?.[0]?.message?.content ?? '{}' mistralRaw = bifrostRes.choices?.[0]?.message?.content ?? '{}'
tokensIn = mistralRes.usage?.prompt_tokens ?? 0 tokensIn = bifrostRes.usage?.prompt_tokens ?? 0
tokensOut = mistralRes.usage?.completion_tokens ?? 0 tokensOut = bifrostRes.usage?.completion_tokens ?? 0
realModel =
bifrostRes.extra_fields?.resolved_model_used ??
(bifrostRes.extra_fields?.provider
? `${bifrostRes.extra_fields.provider}/${tier.model.split('/').slice(1).join('/')}`
: tier.model)
} catch (e: any) { } catch (e: any) {
console.error('[chatbot] Erreur Mistral Small:', e?.message ?? e) console.error('[chatbot] Erreur Bifrost:', e?.message ?? e)
throw createError({ throw createError({
statusCode: 502, statusCode: 502,
statusMessage: 'Erreur appel IA — réessaie dans quelques instants.', statusMessage: 'Erreur appel IA — réessaie dans quelques instants.',
@@ -317,11 +325,16 @@ export default defineEventHandler(async (event) => {
} }
// 8. Log usage (non bloquant) // 8. Log usage (non bloquant)
const coutEur = calcCoutMistralSmall(tokensIn, tokensOut) // Coût réel calculable seulement si le provider ayant répondu est Mistral —
// les autres tiers Bifrost (Groq, Cerebras, Gemini free tier, Cohere) sont free-tier,
// donc cout_eur=0 pour ne pas fausser le circuit breaker avec un tarif Mistral inapplicable.
const isMistral = realModel.toLowerCase().startsWith('mistral')
const coutEur = isMistral ? calcCoutMistralSmall(tokensIn, tokensOut) : 0
logUsage({ logUsage({
nocodbUrl: config.nocodbUrl as string, nocodbUrl: config.nocodbUrl as string,
nocodbToken: config.nocodbToken as string, nocodbToken: config.nocodbToken as string,
statsTableId, statsTableId,
model: realModel,
tokensIn, tokensIn,
tokensOut, tokensOut,
coutEur, coutEur,
+47
View File
@@ -0,0 +1,47 @@
/**
* Bifrost — gateway LLM (remplace les appels directs Mistral)
* Endpoint OpenAI-compatible : POST {bifrostUrl}/v1/chat/completions
* Auth : header x-bf-vk
*
* 2 tiers validés (Mission M3, build Bifrost) :
* RAPIDE — défaut, pas de toggle UI mode rapide/approfondi sur le site actuellement
* APPROFONDI — activable via body.mode === 'approfondi' (prêt pour un futur toggle front)
*
* ⚠ openrouter-oai exclu (bug Bifrost confirmé — 404 HTML sur modèles avec slash)
* ⚠ gemini-oai exige le préfixe "models/" (sinon 403 silencieux)
* ⚠ cerebras/gemma-4-31b RETIRÉ du tier RAPIDE (M4, 15/07) : en JSON mode avec un contexte
* réel (prompt + ~20 fiches), le modèle part en boucle de répétition dégénérée ("1 1 1...")
* dans ~75% des cas où il sert de fallback, produit un JSON invalide, et Bifrost ne bascule
* PAS plus loin dans la chaîne (HTTP 200 côté provider = pas une erreur pour Bifrost). Bug
* constaté en prod sur chatbot-reseaux (1 échec/3), reproduit 3/4 sur appel direct Bifrost.
* Cerebras reste configuré dans Bifrost (dispo pour tier CODE ou reconfiguration future),
* juste plus dans cette chaîne tant que ce n'est pas fiabilisé.
*/
export const BIFROST_TIER_RAPIDE = {
model: 'groq/llama-3.1-8b-instant',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash-lite',
'cohere/command-r-08-2024',
],
}
export const BIFROST_TIER_APPROFONDI = {
model: 'groq/llama-3.3-70b-versatile',
fallbacks: [
'gemini-oai/models/gemini-2.5-flash',
'mistral/mistral-large-latest',
'cohere/command-r-plus-08-2024',
],
}
/** Sélectionne le tier selon le param optionnel body.mode. */
export function pickBifrostTier(mode?: string) {
return mode === 'approfondi' ? BIFROST_TIER_APPROFONDI : BIFROST_TIER_RAPIDE
}
export interface BifrostChatResponse {
choices: { message: { content: string } }[]
usage?: { prompt_tokens: number; completion_tokens: number }
extra_fields?: { provider?: string; resolved_model_used?: string }
}