HubMix

Documentation

Flutter et Dart

Le SDK pub.dev de Gaspard pour Flutter (Android, iOS, web, desktop) et Dart : clé widget, consentement et flux.

SDK officiel Dart et Flutter du hub HubMix. Il couvre toute l'API publique /v1/public : Gaspard, l'assistant IA (conversations, réponses en flux, recherche dans la base de connaissances, consentement et droits des visiteurs, nLPD / RGPD) et, depuis la 1.1.0, le tuteur, la confiance (signaux d'arnaque des annonces), les embeddings et la vérification des webhooks (portail développeurs retiré le 28 octobre 2026 : plus aucun webhook n'est envoyé).

  • Dart pur (aucune dépendance à Flutter) : applications Flutter Android, iOS, web, Windows, macOS, Linux, serveurs Dart et outils en ligne de commande.
  • Deux dépendances : http (vous pouvez injecter votre propre http.Client) et crypto (vérification des webhooks).

Installation #

Terminal
dart pub add valais_connect_gaspard     # projet Dart
flutter pub add valais_connect_gaspard  # application Flutter
Dart
import 'package:valais_connect_gaspard/valais_connect_gaspard.dart';

Deux sortes de clés #

Clé Où Configuration
Secrète vck_live_… Serveur uniquement (backend Dart, CLI) GaspardClient(apiKey: …), et endUser / forEndUser(id) pour identifier vos utilisateurs
Publiable vcp_live_… Application Flutter, site web GaspardClient(publishableKey: …, origin: …) puis openSession(consent: true)
  • Une clé secrète ne doit jamais être livrée dans une application mobile ou une page web : le SDK la refuse sur Flutter web (ArgumentError).
  • Hors navigateur (Android, iOS, desktop), une clé publiable exige origin : l'une des origines déclarées pour cette clé dans votre espace développeur (envoyée en en-tête Origin). Dans un navigateur, c'est le navigateur qui envoie l'origine ; le SDK n'envoie alors ni Origin ni User-Agent.

Exemple Flutter : un chat avec la clé publiable #

Le visiteur lit l'avis de confidentialité et l'accepte, puis la session est ouverte. Le jeton de visiteur (vst_…, valable 30 jours) est conservé par votre application, ici avec shared_preferences (flutter pub add shared_preferences ; ce n'est pas une dépendance du SDK) : le visiteur retrouve ainsi ses conversations au prochain lancement.

Dart
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:valais_connect_gaspard/valais_connect_gaspard.dart';

const _tokenKey = 'gaspard_visitor_token';

final gaspard = GaspardClient(
  publishableKey: 'vcp_live_…',
  // Une origine déclarée pour la clé (ignorée sur Flutter web, où le navigateur l'envoie).
  origin: 'https://app.mon-ecole.ch',
);

class GaspardChat extends StatefulWidget {
  const GaspardChat({super.key});

  @override
  State<GaspardChat> createState() => _GaspardChatState();
}

class _GaspardChatState extends State<GaspardChat> {
  final _question = TextEditingController();
  String? _conversationId;
  String _answer = '';
  bool _busy = false;

  /// Restaure le jeton enregistré, ou demande le consentement et ouvre une session.
  Future<bool> _ensureSession() async {
    final prefs = await SharedPreferences.getInstance();
    final saved = prefs.getString(_tokenKey);
    if (saved != null) {
      gaspard.setVisitorToken(saved);
      final status = await gaspard.consent();
      if (status.given && status.upToDate) return true;
    }
    final notice = await gaspard.privacyNotice(locale: 'fr');
    if (!mounted) return false;
    final accepted = await showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('Confidentialité'),
        content: SingleChildScrollView(child: Text(notice.text)),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context, false),
            child: const Text('Refuser'),
          ),
          FilledButton(
            onPressed: () => Navigator.pop(context, true),
            child: const Text('Accepter'),
          ),
        ],
      ),
    );
    if (accepted != true) return false;
    final session = await gaspard.openSession(consent: true, locale: 'fr');
    await prefs.setString(_tokenKey, session.token);
    return true;
  }

  Future<void> _send() async {
    final text = _question.text.trim();
    if (text.isEmpty || _busy) return;
    setState(() {
      _busy = true;
      _answer = '';
    });
    try {
      if (!await _ensureSession()) return;
      _conversationId ??= (await gaspard.startConversation(locale: 'fr')).id;
      await for (final event in gaspard.askStream(_conversationId!, text)) {
        switch (event) {
          case GaspardDelta(:final text):
            setState(() => _answer += text);
          case GaspardAnswerEvent(:final answer):
            setState(() => _answer = answer.text); // texte final, sources dans answer.sources
        }
      }
      _question.clear();
    } on GaspardException catch (error) {
      if (error.code == 'session_required' || error.status == 401) {
        // Jeton expiré ou effacé : on l'oublie, une nouvelle session sera ouverte.
        gaspard.setVisitorToken(null);
        (await SharedPreferences.getInstance()).remove(_tokenKey);
      }
      if (error.code == 'conversation_full') _conversationId = null;
      setState(() => _answer = error.isTransient
          ? 'Gaspard est très sollicité, réessayez dans un instant.'
          : 'Désolé, une erreur est survenue (${error.code}).');
    } finally {
      if (mounted) setState(() => _busy = false);
    }
  }

  @override
  Widget build(BuildContext context) => Column(
        children: [
          Expanded(child: SingleChildScrollView(child: Text(_answer))),
          TextField(
            controller: _question,
            maxLength: 2000,
            onSubmitted: (_) => _send(),
            decoration: InputDecoration(
              hintText: 'Votre question',
              suffixIcon: IconButton(
                icon: const Icon(Icons.send),
                onPressed: _busy ? null : _send,
              ),
            ),
          ),
        ],
      );
}

Droits du visiteur, avec le même client : consent(), giveConsent(noticeVersion, allowTraining: …), withdrawConsent(), exportData() (droit d'accès) et eraseData() (droit à l'effacement ; supprimez ensuite le jeton enregistré).

Exemple serveur Dart : clé secrète #

Dart
import 'dart:io';

import 'package:valais_connect_gaspard/valais_connect_gaspard.dart';

Future<void> main() async {
  final client = GaspardClient(apiKey: Platform.environment['GASPARD_API_KEY']);
  // Un client dérivé par utilisateur : seuls ses échanges lui sont visibles
  // (en-tête X-End-User ; seul un hachage à clé de l'identifiant est conservé).
  final gaspard = client.forEndUser('user-42');

  final conversation = await gaspard.startConversation(locale: 'fr', page: 'admissions');

  // Réponse complète…
  final answer = await gaspard.ask(conversation.id, 'Où étudier à Montréal ?');
  print(answer.text);

  // … ou au fil de l'écriture.
  await for (final event in gaspard.askStream(conversation.id, 'Et le logement ?')) {
    switch (event) {
      case GaspardDelta(:final text):
        stdout.write(text);
      case GaspardAnswerEvent(:final answer):
        print('\n${answer.listings.length} annonce(s), ${answer.remainingMessages} question(s) restantes');
    }
  }

  await gaspard.feedback(answer.messageId, GaspardRating.up);

  final found = await client.searchKnowledge(
    'Montréal universités',
    limit: 3,
    topics: [KnowledgeTopic.geography],
  );
  for (final passage in found.passages) {
    print('${passage.title} (${passage.score})');
  }

  client.close();
}

Produits du hub (clé secrète, serveur Dart) #

Le tuteur, la confiance et les embeddings sont activés par application par l'équipe HubMix (sur demande) ; sans le produit, l'API répond 403 insufficient_scope. Ils demandent une clé secrète : avec une clé publiable (application Flutter), l'appel lève un ArgumentError avant toute requête. Les valeurs que l'API peut étendre (niveaux, verdicts, signaux) sont des chaînes.

Dart
final gaspard = GaspardClient(apiKey: Platform.environment['VC_API_KEY']);

// Confiance : à la publication d'une annonce
final result = await gaspard.trust.assess([
  TrustListing(kind: 'housing', title: annonce.titre, description: annonce.texte,
      priceAmount: annonce.loyer, currency: 'XOF', countryCode: 'SN', city: 'Dakar'),
]);
if (result.assessments.first.withheld) {
  // à faire vérifier ; result.assessments.first.findings dit pourquoi
}

// Tuteur : des cartes de révision
final cartes = await gaspard.tutor.flashcards(
  lesson: Lesson(title: 'La photosynthèse', content: texteDuCours),
  count: 8,
  learner: const Learner(level: 'bachelor', countryCode: 'SN'),
  locale: 'fr',
);

// Embeddings : un vecteur par texte, dans le même ordre
final vecteurs = (await gaspard.embeddings.create(['chambre à louer', 'room for rent'])).embeddings;

Configuration #

Dart
GaspardClient(
  apiKey: …,                 // ou publishableKey: … (exactement l'une des deux)
  origin: …,                 // clé publiable hors navigateur
  endUser: …,                // clé secrète : identifiant de votre utilisateur (1–128 caractères ASCII imprimables)
  baseUrl: Uri.parse('https://hubmix.dealmood-solutions.com'), // /v1/public est ajouté
  timeout: const Duration(seconds: 60),  // par requête ; pour un flux, jusqu'au début de la réponse
  maxRetries: 2,             // 0 pour désactiver les nouvelles tentatives
  userAgent: 'mon-app/2.1',  // défaut : gaspard-dart/1.1.0 (jamais envoyé dans un navigateur)
  httpClient: monClient,     // http.Client injecté (non fermé par close())
);

Chaque méthode accepte aussi requestId: (8–64 caractères [A-Za-z0-9._-]), envoyé en X-Request-Id pour retrouver la requête dans les journaux.

Erreurs et nouvelles tentatives #

Tout échec est une GaspardException :

Champ Contenu
status statut HTTP, 0 sans réponse
code code de l'API (assistant_busy, rate_limited, token_quota_exceeded, consent_required, session_required, conversation_full, not_found…), sinon http_<statut> ; network_error, timeout, invalid_response, stream_incomplete côté client
message explication destinée aux développeurs
requestId identifiant de la requête (à citer au support)
retryAfter Duration demandée par Retry-After, sinon null

Les erreurs de configuration (aucune clé ou les deux, clé secrète sur le web, consent: false, origin manquante…) sont des ArgumentError, levées avant toute requête.

Le SDK réessaie automatiquement (jusqu'à maxRetries fois) : 503 assistant_busy, 429 rate_limited et, pour GET et DELETE seulement, les erreurs réseau et les délais dépassés. Il attend Retry-After (au-delà de 30 s, il abandonne et lève l'erreur), sinon 0,5 s × 2^tentative. Jamais pour token_quota_exceeded ni une autre erreur 4xx, ni pour un flux déjà commencé. Reposer une question est sans risque : le serveur n'enregistre un échange qu'avec sa réponse.

Dans un flux (askStream), un événement error du serveur termine le flux par une GaspardException portant son code (503 pour assistant_busy / assistant_unavailable, 409 pour conversation_full) ; un flux interrompu sans réponse finale lève stream_incomplete. Annuler l'abonnement ferme la connexion.