Compare commits
4
Commits
e5d57e0053
...
b5aca80bbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5aca80bbc | ||
|
|
946ed53aba | ||
|
|
d06c09a476 | ||
|
|
4291fe7529 |
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
+269
-6
@@ -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,7 +684,112 @@ useSeoMeta({
|
|||||||
margin: 0 -0.5rem;
|
margin: 0 -0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fmhy-tree-wrap {
|
/* ── 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 {
|
||||||
padding: 0.875rem 0.75rem;
|
padding: 0.875rem 0.75rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user