mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
feat(r6): R6.5 — Assistant mobile (chat sourcé + dictée)
Maquette dédiée validée (docs/02-design/maquettes/maquette-mobile-assistant.html, 3 écrans) — la seule sous-étape de R6 à avoir eu son propre tour de design-first plutôt qu'une exécution directe de la maquette R6 initiale, un chat sourcé étant un patron d'écran neuf. D1 un échange à la fois ; D2 "Voir le document" → métadonnées seules (R6.3), "Ouvrir l'OT" → fiche R4 ; D3 refus chiffré + avertissement permanents, identiques au web ; D4 Assistant rejoint Pilotage pour tout rôle avec WORK_ORDERS.view ; D5 (ajoutée en revue) — la question peut être tapée OU dictée, même pipeline que la dictée déjà livrée en clôture (ADR-004 §5, faster-whisper local, audio jamais persisté), la transcription remplit le champ, jamais d'envoi automatique. api/assistant.ts (useAskAssistant, 503 géré comme le web). Écran Assistant : chat un échange à la fois, citations numérotées, sources avec extrait exact, refus honnête chiffré + Reformuler. Micro dans la barre de saisie : réutilise telle quelle la mécanique de cloture.tsx (useAudioRecorder, setAudioModeAsync, useTranscription, purge quoi qu'il arrive) — pipeline déjà recetté sur iPhone physique (01/08), aucun nouveau bug à découvrir. Fiche document (bibliotheque/[id].tsx, nouveau) — la liste R6.3 y mène aussi désormais. Menu : Assistant route réellement — le Menu R6 n'a plus d'entrée "à venir" dans les 4 groupes (Catégories exceptée, admin, hors périmètre mobile). R6 fonctionnellement complet (Exploitation/Parc/Ressources/Pilotage, Assistant inclus, pour tous les rôles) — reste une recette complète avant de considérer R6 close au sens R0→R5. Typecheck propre, 17 tests Jest, lint 5/5 paquets. Contrat non touché. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -54,7 +54,7 @@ const GROUPES: { titre: string; liens: LienMenu[] }[] = [
|
||||
titre: 'Pilotage',
|
||||
liens: [
|
||||
{ libelle: 'Statistiques', ico: '◈', permission: ['ANALYTICS', 'view'], route: '/statistiques' },
|
||||
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], aVenir: 'L’assistant IA reste sur le grand écran et la clôture mobile pour l’instant — un chat sourcé est un nouveau patron d’écran, pas encore maquetté sur mobile.' },
|
||||
{ libelle: 'Assistant', ico: '✦', permission: ['WORK_ORDERS', 'view'], route: '/assistant' },
|
||||
{ libelle: 'Personnes & équipes', ico: '◎', permission: ['PEOPLE_TEAMS', 'view'], route: '/personnes' },
|
||||
],
|
||||
},
|
||||
|
||||
372
apps/mobile/app/assistant/index.tsx
Normal file
372
apps/mobile/app/assistant/index.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { router } from 'expo-router';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||
import {
|
||||
RecordingPresets,
|
||||
requestRecordingPermissionsAsync,
|
||||
setAudioModeAsync,
|
||||
useAudioRecorder,
|
||||
} from 'expo-audio';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import type { AssistantAnswer, AssistantExcerpt } from '@siop/shared';
|
||||
import { useAskAssistant } from '@/api/assistant';
|
||||
import { useTranscription } from '@/api/exploitation';
|
||||
import { BoutonTel, EnteteFiche } from '@/composants/ui';
|
||||
import { useTokens, type Tokens } from '@/theme/tokens';
|
||||
|
||||
/** Assistant — maquette « Assistant mobile » (validée 02/08) : même contenu
|
||||
* et mêmes règles que le web (D2 « sourcé ou silencieux », ADR-004 §4 —
|
||||
* le service IA n'est jamais appelé directement, toujours via l'API).
|
||||
* D5 : la question peut être tapée OU dictée — même pipeline que la
|
||||
* dictée déjà livrée en clôture (ADR-004 §5), la transcription REMPLIT le
|
||||
* champ (éditable), aucun envoi automatique. */
|
||||
|
||||
interface Echange {
|
||||
question: string;
|
||||
reponse?: AssistantAnswer;
|
||||
erreur?: string;
|
||||
}
|
||||
|
||||
export default function PageAssistant() {
|
||||
const t = useTokens();
|
||||
const ask = useAskAssistant();
|
||||
const transcrire = useTranscription();
|
||||
const recorder = useAudioRecorder(RecordingPresets.HIGH_QUALITY);
|
||||
const [question, setQuestion] = useState('');
|
||||
const [echanges, setEchanges] = useState<Echange[]>([]);
|
||||
const [enregistrement, setEnregistrement] = useState(false);
|
||||
const saisieRef = useRef<TextInput>(null);
|
||||
|
||||
const demarrerDictee = async () => {
|
||||
const { granted } = await requestRecordingPermissionsAsync();
|
||||
if (!granted) return;
|
||||
// iOS refuse recorder.record() sans autorisation explicite de la session
|
||||
// audio (RecordingDisabledException) — même correctif qu'en clôture.
|
||||
await setAudioModeAsync({ allowsRecording: true, playsInSilentMode: true });
|
||||
await recorder.prepareToRecordAsync();
|
||||
recorder.record();
|
||||
setEnregistrement(true);
|
||||
};
|
||||
|
||||
const terminerDictee = async () => {
|
||||
await recorder.stop();
|
||||
await setAudioModeAsync({ allowsRecording: false });
|
||||
setEnregistrement(false);
|
||||
const uri = recorder.uri;
|
||||
if (!uri) return;
|
||||
transcrire.mutate(
|
||||
{ uri, nom: 'question.m4a', mime: 'audio/m4a' },
|
||||
{
|
||||
onSuccess: (resultat) => setQuestion(resultat.text),
|
||||
// Loi 09-08 (D5) : l'audio ne survit jamais à l'appel, succès ou pas.
|
||||
onSettled: () => void FileSystem.deleteAsync(uri, { idempotent: true }),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const envoyer = () => {
|
||||
const q = question.trim();
|
||||
if (q.length < 3 || ask.isPending) return;
|
||||
setQuestion('');
|
||||
setEchanges((liste) => [...liste, { question: q }]);
|
||||
ask.mutate(
|
||||
{ question: q },
|
||||
{
|
||||
onSuccess: (reponse) =>
|
||||
setEchanges((liste) =>
|
||||
liste.map((e, i) => (i === liste.length - 1 ? { ...e, reponse } : e)),
|
||||
),
|
||||
onError: (erreur) =>
|
||||
setEchanges((liste) =>
|
||||
liste.map((e, i) => (i === liste.length - 1 ? { ...e, erreur: erreur.message } : e)),
|
||||
),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<View style={{ flex: 1, padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre="Assistant" />
|
||||
<ScrollView contentContainerStyle={{ gap: 10, paddingBottom: 8, flexGrow: 1 }}>
|
||||
{echanges.length === 0 ? (
|
||||
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Posez une question sur vos notices, vos historiques d'intervention ou vos
|
||||
procédures — chaque réponse cite ses sources. Quand le corpus ne porte pas la
|
||||
réponse, l'assistant le dit au lieu d'inventer.
|
||||
</Text>
|
||||
) : null}
|
||||
{echanges.map((e, i) => (
|
||||
<View key={i} style={{ gap: 8 }}>
|
||||
<Text
|
||||
style={{
|
||||
alignSelf: 'flex-end',
|
||||
backgroundColor: t.primaire,
|
||||
color: '#fff',
|
||||
borderRadius: 13,
|
||||
borderBottomRightRadius: 3,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 9,
|
||||
maxWidth: '85%',
|
||||
fontFamily: 'Manrope_600SemiBold',
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
{e.question}
|
||||
</Text>
|
||||
{e.reponse ? (
|
||||
<Reponse reponse={e.reponse} t={t} surReformuler={() => saisieRef.current?.focus()} />
|
||||
) : e.erreur ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 12.5 }}>
|
||||
{e.erreur}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 12.5 }}>
|
||||
Recherche dans le corpus…
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{enregistrement ? (
|
||||
<View
|
||||
style={{
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordure,
|
||||
borderRadius: 13,
|
||||
backgroundColor: t.surface,
|
||||
padding: 16,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: t.prioBloque,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 18 }}>🎙</Text>
|
||||
</View>
|
||||
<Text style={{ color: t.encre2, fontFamily: 'Manrope_400Regular', fontSize: 11.5 }}>
|
||||
Enregistrement en cours…
|
||||
</Text>
|
||||
<BoutonTel
|
||||
libelle="■ Terminer la dictée"
|
||||
surAppui={() => void terminerDictee()}
|
||||
/>
|
||||
</View>
|
||||
) : (
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderRadius: 10,
|
||||
backgroundColor: t.surface,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
}}
|
||||
>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Dicter la question"
|
||||
disabled={transcrire.isPending}
|
||||
onPress={() => void demarrerDictee()}
|
||||
style={{
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
backgroundColor: t.primaireDoux,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontSize: 13 }}>🎙</Text>
|
||||
</Pressable>
|
||||
<TextInput
|
||||
ref={saisieRef}
|
||||
accessibilityLabel="Poser une question"
|
||||
value={transcrire.isPending ? 'Transcription…' : question}
|
||||
editable={!transcrire.isPending}
|
||||
onChangeText={setQuestion}
|
||||
onSubmitEditing={envoyer}
|
||||
maxLength={500}
|
||||
placeholder="Poser une question…"
|
||||
placeholderTextColor={t.encre3}
|
||||
style={{ flex: 1, color: t.encre, fontFamily: 'Manrope_600SemiBold', fontSize: 13 }}
|
||||
/>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Envoyer"
|
||||
disabled={question.trim().length < 3 || ask.isPending}
|
||||
onPress={envoyer}
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: question.trim().length < 3 || ask.isPending ? t.encre3 : t.primaire,
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
fontSize: 12.5,
|
||||
}}
|
||||
>
|
||||
Envoyer
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
{transcrire.isError ? (
|
||||
<Text style={{ color: t.danger, fontFamily: 'Manrope_600SemiBold', fontSize: 11.5 }}>
|
||||
{transcrire.error.message}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Reponse({
|
||||
reponse,
|
||||
t,
|
||||
surReformuler,
|
||||
}: {
|
||||
reponse: AssistantAnswer;
|
||||
t: Tokens;
|
||||
surReformuler: () => void;
|
||||
}) {
|
||||
if (reponse.mode === 'REFUSAL') {
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
borderWidth: 1.5,
|
||||
borderColor: t.bordureForte,
|
||||
borderStyle: 'dashed',
|
||||
borderRadius: 13,
|
||||
padding: 12,
|
||||
gap: 8,
|
||||
backgroundColor: t.surface,
|
||||
}}
|
||||
>
|
||||
<Text style={{ fontFamily: 'Manrope_800ExtraBold', fontSize: 12.5, color: t.encre }}>
|
||||
Je ne trouve pas de source fiable dans votre bibliothèque — je préfère ne pas inventer.
|
||||
</Text>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 11.5, color: t.encre2 }}>
|
||||
J'ai cherché dans {reponse.corpus.documents} document
|
||||
{reponse.corpus.documents > 1 ? 's' : ''} indexé
|
||||
{reponse.corpus.documents > 1 ? 's' : ''} et {reponse.corpus.reports} bilan
|
||||
{reponse.corpus.reports > 1 ? 's' : ''} d'intervention : rien d'assez proche de votre
|
||||
question.
|
||||
</Text>
|
||||
<BoutonTel libelle="Reformuler ma question" variante="contour" surAppui={surReformuler} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
backgroundColor: t.surface,
|
||||
borderColor: t.bordure,
|
||||
borderWidth: 1,
|
||||
borderRadius: 13,
|
||||
borderBottomLeftRadius: 3,
|
||||
padding: 11,
|
||||
gap: 9,
|
||||
}}
|
||||
>
|
||||
{reponse.mode === 'GENERATED' && reponse.answer ? (
|
||||
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
|
||||
{reponse.answer}
|
||||
</Text>
|
||||
) : (
|
||||
<Text style={{ fontFamily: 'Manrope_500Medium', fontSize: 12.5, color: t.encre }}>
|
||||
Voici ce que portent vos sources — les extraits sont cités tels quels.
|
||||
</Text>
|
||||
)}
|
||||
<View
|
||||
style={{ gap: 7, borderTopWidth: 1, borderTopColor: t.bordure, paddingTop: 9 }}
|
||||
>
|
||||
{reponse.excerpts.map((ex, i) => (
|
||||
<Source key={`${ex.locator}-${i}`} ex={ex} no={i + 1} t={t} />
|
||||
))}
|
||||
</View>
|
||||
<View
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
gap: 6,
|
||||
backgroundColor: t.safranDoux,
|
||||
borderRadius: 9,
|
||||
padding: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: t.alerte, fontSize: 10.5, fontFamily: 'Manrope_600SemiBold', flex: 1 }}>
|
||||
⚠ L'IA propose, vous validez : vérifiez la notice avant d'agir sur l'appareil.
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Source({ ex, no, t }: { ex: AssistantExcerpt; no: number; t: Tokens }) {
|
||||
const ouvrir = () => {
|
||||
if (ex.sourceType === 'DOCUMENT' && ex.documentId) router.push(`/bibliotheque/${ex.documentId}`);
|
||||
else if (ex.workOrderId) router.push(`/ot/${ex.workOrderId}`);
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={ouvrir}
|
||||
style={{ backgroundColor: t.surface2, borderRadius: 9, padding: 9, gap: 3 }}
|
||||
>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 7 }}>
|
||||
<Text
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 5,
|
||||
backgroundColor: t.primaireDoux,
|
||||
color: t.primaire,
|
||||
fontSize: 10,
|
||||
fontFamily: 'Manrope_800ExtraBold',
|
||||
textAlign: 'center',
|
||||
lineHeight: 16,
|
||||
}}
|
||||
>
|
||||
{no}
|
||||
</Text>
|
||||
<Text
|
||||
numberOfLines={1}
|
||||
style={{ flex: 1, fontFamily: 'Manrope_700Bold', fontSize: 11.5, color: t.encre }}
|
||||
>
|
||||
{ex.title}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ fontFamily: 'Manrope_400Regular', fontSize: 10.5, color: t.encre2, marginLeft: 23 }}>
|
||||
{ex.sourceType === 'DOCUMENT' ? 'Bibliothèque' : 'Historique'} · {ex.locator}
|
||||
</Text>
|
||||
<Text
|
||||
style={{
|
||||
fontFamily: 'Manrope_500Medium',
|
||||
fontStyle: 'italic',
|
||||
fontSize: 11,
|
||||
color: t.encre2,
|
||||
marginLeft: 23,
|
||||
borderLeftWidth: 2,
|
||||
borderLeftColor: t.safran,
|
||||
paddingLeft: 7,
|
||||
}}
|
||||
>
|
||||
« {ex.content} »
|
||||
</Text>
|
||||
<Text style={{ marginLeft: 23, color: t.primaire, fontFamily: 'Manrope_700Bold', fontSize: 11 }}>
|
||||
{ex.sourceType === 'DOCUMENT' ? 'Voir le document' : "Ouvrir l'OT"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
52
apps/mobile/app/bibliotheque/[id].tsx
Normal file
52
apps/mobile/app/bibliotheque/[id].tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { ScrollView, Text } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useDocuments } from '@/api/ressources';
|
||||
import { Carte, EnteteFiche, LigneInfo } from '@/composants/ui';
|
||||
import { useTokens } from '@/theme/tokens';
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
NOTICE: 'Notice',
|
||||
CERTIFICATE: 'Certificat',
|
||||
PHOTO: 'Photo',
|
||||
OTHER: 'Autre',
|
||||
};
|
||||
|
||||
function taille(octets: number): string {
|
||||
if (octets < 1024) return `${octets} o`;
|
||||
if (octets < 1024 * 1024) return `${Math.round(octets / 1024)} Ko`;
|
||||
return `${(octets / (1024 * 1024)).toFixed(1)} Mo`;
|
||||
}
|
||||
|
||||
/** Fiche document — R6.5. Métadonnées seules (D2, comme la bibliothèque
|
||||
* R6.3) : l'ouverture du fichier lui-même demande `expo-sharing`, pas
|
||||
* encore une dépendance du projet — ouvrir depuis le web pour l'instant.
|
||||
* Destination de « Voir le document » depuis les sources de l'Assistant. */
|
||||
export default function PageFicheDocument() {
|
||||
const t = useTokens();
|
||||
const { id } = useLocalSearchParams<{ id: string }>();
|
||||
const { data: documents } = useDocuments();
|
||||
const doc = (documents ?? []).find((d) => d.id === id);
|
||||
if (!doc) return null;
|
||||
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: t.fond }} edges={['top']}>
|
||||
<ScrollView contentContainerStyle={{ padding: 14, gap: 10 }}>
|
||||
<EnteteFiche titre={doc.fileName} />
|
||||
<Carte titre="Document">
|
||||
<LigneInfo nom="Type" valeur={KIND_LABEL[doc.kind] ?? doc.kind} />
|
||||
<LigneInfo nom="Taille" valeur={taille(doc.size)} />
|
||||
<LigneInfo nom="Rattaché à" valeur={doc.assetReference ?? doc.workOrderReference ?? '—'} />
|
||||
<LigneInfo nom="Ajouté par" valeur={doc.uploadedByName ?? '—'} />
|
||||
<LigneInfo
|
||||
nom="Ajouté le"
|
||||
valeur={new Intl.DateTimeFormat('fr-FR', { dateStyle: 'long' }).format(new Date(doc.createdAt))}
|
||||
/>
|
||||
</Carte>
|
||||
<Text style={{ color: t.encre3, fontFamily: 'Manrope_400Regular', fontSize: 11.5, textAlign: 'center' }}>
|
||||
Ouvrir le fichier se fait depuis le web pour l'instant.
|
||||
</Text>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FlatList, Text, View } from 'react-native';
|
||||
import { router } from 'expo-router';
|
||||
import { FlatList, Pressable, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useDocuments } from '@/api/ressources';
|
||||
import { EnteteFiche } from '@/composants/ui';
|
||||
@@ -48,7 +49,9 @@ export default function PageBibliotheque() {
|
||||
</Text>
|
||||
}
|
||||
renderItem={({ item: d }) => (
|
||||
<View
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
onPress={() => router.push(`/bibliotheque/${d.id}`)}
|
||||
style={{
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -71,7 +74,7 @@ export default function PageBibliotheque() {
|
||||
.join(' · ')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Pressable>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
26
apps/mobile/src/api/assistant.ts
Normal file
26
apps/mobile/src/api/assistant.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import type { AssistantAsk } from '@siop/shared';
|
||||
import { api } from './client';
|
||||
|
||||
/** Hook R6.5 — l'assistant passe par l'API NestJS (le service IA n'est
|
||||
* jamais appelé depuis l'app, ADR-004 §4). Le 503 est un état ATTENDU du
|
||||
* contrat (service éteint ou pas encore déployé) — même message que le web. */
|
||||
|
||||
async function unwrap<T>(res: { data?: T; error?: unknown; response: Response }): Promise<T> {
|
||||
if (res.response.status === 503) {
|
||||
throw new Error('Assistant indisponible pour le moment — réessayez dans un instant.');
|
||||
}
|
||||
if (res.error || res.data === undefined) {
|
||||
const message =
|
||||
(res.error as { message?: string } | undefined)?.message ??
|
||||
`Le serveur a répondu ${res.response.status}`;
|
||||
throw new Error(Array.isArray(message) ? message.join(' — ') : message);
|
||||
}
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export function useAskAssistant() {
|
||||
return useMutation({
|
||||
mutationFn: async (body: AssistantAsk) => unwrap(await api.POST('/assistant/ask', { body })),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user