mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 20:51:53 +00:00
Deux causes cumulées : le ScrollView du chat n'avait pas style={flex:1}
(seul contentContainerStyle était posé), donc pas correctement borné dans
son parent flex — pouvait empiéter sur le composeur du dessous. Et le
TextInput multiligne utilisait maxHeight seul, qu'iOS ignore parfois en
laissant le champ grandir avec le contenu au lieu de défiler en interne.
Fix : ScrollView proprement borné (style={flex:1}) ; TextInput à hauteur
FIXE (96) + scrollEnabled, qui garantit le défilement interne au-delà
plutôt qu'une dépendance à un maxHeight pas toujours respecté.
Typecheck propre, 17 tests Jest, lint 5/5 paquets.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
381 lines
13 KiB
TypeScript
381 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 style={{ flex: 1 }} contentContainerStyle={{ gap: 10, paddingBottom: 8 }}>
|
|
{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={{
|
|
// Hauteur FIXE plutôt que maxHeight seul : sur iOS, un
|
|
// TextInput multiligne ignore parfois maxHeight et grandit
|
|
// avec le contenu, poussant/recouvrant les boutons du
|
|
// dessous (trouvé en recette, 02/08). Hauteur fixe +
|
|
// scrollEnabled = défilement interne garanti au-delà.
|
|
height: 96,
|
|
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>
|
|
);
|
|
}
|