Files
siop2/apps/mobile/app/assistant/index.tsx
pr-daaif 6f74fd4206 fix(mobile): expo-file-system deleteAsync — import legacy subpath
SDK 57 deprecates the module-level functions (deleteAsync included) in
favor of the File/Directory classes, and now throws at runtime in dev
instead of just warning — surfaced as a red-box crash on the Assistant
screen right after transcription (found in recette, 02/08). The dictée
purge in cloture.tsx uses the exact same call and was silently affected
too. Fix: import from 'expo-file-system/legacy' in both — same function
signature, no behavior change, just the non-deprecated entry point.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-02 13:12:44 +01:00

376 lines
13 KiB
TypeScript

// SDK 57 : les fonctions module-level (dont deleteAsync) sont dépréciées au
// profit des classes File/Directory et lèvent désormais en développement —
// import explicite du sous-chemin legacy, comportement inchangé sinon.
import * as FileSystem from 'expo-file-system/legacy';
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>
);
}