Files
siop2/apps/mobile/app/assistant/index.tsx
pr-daaif 63656565f7 fix(mobile): Assistant — question dictée pleinement visible avant envoi
Retour de recette (02/08) : la barre de saisie sur une ligne masquait le
texte transcrit dès qu'il dépassait quelques mots, empêchant la relecture
avant envoi (D5 de la maquette Assistant). Remplacée par une zone
multiligne pleine largeur (jusqu'à 160px, défilement interne au-delà) —
même patron que la description de clôture (cloture.tsx) — avec "Envoyer"
comme geste séparé et délibéré en dessous, pas accolé au champ.

Typecheck propre, 17 tests Jest, lint 5/5 paquets.

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

377 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={{
gap: 8,
borderWidth: 1.5,
borderColor: t.bordureForte,
borderRadius: 10,
backgroundColor: t.surface,
padding: 10,
}}
>
{/* Zone pleine largeur, multiligne : la question dictée doit se
* lire EN ENTIER avant d'envoyer (trouvé en recette, 02/08
* une barre d'une ligne masquait le texte transcrit long). */}
<TextInput
ref={saisieRef}
accessibilityLabel="Poser une question"
multiline
scrollEnabled
value={transcrire.isPending ? 'Transcription' : question}
editable={!transcrire.isPending}
onChangeText={setQuestion}
maxLength={500}
placeholder="Poser une question, ou dictez avec 🎙…"
placeholderTextColor={t.encre3}
style={{
minHeight: 44,
maxHeight: 160,
color: t.encre,
fontFamily: 'Manrope_600SemiBold',
fontSize: 13.5,
textAlignVertical: 'top',
}}
/>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 8 }}>
<Pressable
accessibilityRole="button"
accessibilityLabel="Dicter la question"
disabled={transcrire.isPending}
onPress={() => void demarrerDictee()}
style={{
width: 30,
height: 30,
borderRadius: 15,
backgroundColor: t.primaireDoux,
alignItems: 'center',
justifyContent: 'center',
}}
>
<Text style={{ fontSize: 14 }}>🎙</Text>
</Pressable>
<View style={{ flex: 1 }}>
<BoutonTel
libelle="Envoyer"
desactive={question.trim().length < 3 || ask.isPending || transcrire.isPending}
surAppui={envoyer}
/>
</View>
</View>
</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>
);
}