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 proprehttp.Client) etcrypto(vérification des webhooks).
Installation #
dart pub add valais_connect_gaspard # projet Dart
flutter pub add valais_connect_gaspard # application Flutterimport '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êteOrigin). Dans un navigateur, c'est le navigateur qui envoie l'origine ; le SDK n'envoie alors niOriginniUser-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.
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 #
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.
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 #
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.