import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Alert, FlatList, RefreshControl, StyleSheet, TextInput, View, } from 'react-native' import { useFocusEffect } from '@react-navigation/native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import type { KnowledgeDocument } from '@d3ro/api-client' import { useI18n, type TFunction } from '@d3ro/i18n' import { useAuth } from '../lib/auth-context' import { useMobilePreferences } from '../lib/preferences-context' import { ThemeButton, ThemeCard, ThemeText } from '../theme/themed-components' import { pickKnowledgeTextFile } from '../features/knowledge/knowledge-file-picker' import { createKnowledgeDocument, deleteKnowledgeDocument, indexKnowledgeDocument, KnowledgeServiceError, listKnowledgeDocuments, mergeKnowledgeDocuments, searchKnowledge, subscribeToKnowledgeDocuments, type KnowledgeSearchResult, type KnowledgeServiceErrorCode, } from '../features/knowledge/knowledge-service' export default function KnowledgeScreen(): React.ReactElement { const insets = useSafeAreaInsets() const { t, formatDate } = useI18n() const { user, session } = useAuth() const { palette } = useMobilePreferences() const styles = useMemo(() => createStyles(palette), [palette]) const requestGeneration = useRef(0) const searchController = useRef(null) const indexController = useRef(null) const importInFlight = useRef(false) const indexInFlight = useRef(false) const queryInFlight = useRef(false) const deletingIds = useRef(new Set()) const [documents, setDocuments] = useState([]) const [total, setTotal] = useState(0) const [page, setPage] = useState(0) const [hasMore, setHasMore] = useState(false) const [documentSearch, setDocumentSearch] = useState('') const [activeDocumentSearch, setActiveDocumentSearch] = useState('') const [query, setQuery] = useState('') const [results, setResults] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [loadingMore, setLoadingMore] = useState(false) const [importing, setImporting] = useState(false) const [indexingId, setIndexingId] = useState(null) const [deletingId, setDeletingId] = useState(null) const [querying, setQuerying] = useState(false) const [connected, setConnected] = useState(false) const [error, setError] = useState(null) const [notice, setNotice] = useState(null) const loadFirstPage = useCallback(async (pullToRefresh = false): Promise => { const generation = ++requestGeneration.current if (user === null) { setDocuments([]) setTotal(0) setHasMore(false) setError('auth') setLoading(false) setRefreshing(false) return } if (pullToRefresh) setRefreshing(true) else setLoading(true) setError(null) try { const result = await listKnowledgeDocuments({ userId: user.id, search: activeDocumentSearch, page: 0, }) if (generation !== requestGeneration.current) return setDocuments(result.documents) setTotal(result.total) setPage(0) setHasMore(result.hasMore) } catch (requestError) { if (generation === requestGeneration.current) setError(errorCode(requestError)) } finally { if (generation === requestGeneration.current) { setLoading(false) setRefreshing(false) } } }, [activeDocumentSearch, user]) useFocusEffect(useCallback(() => { void loadFirstPage(false) return () => { requestGeneration.current += 1 searchController.current?.abort() indexController.current?.abort() } }, [loadFirstPage])) useEffect(() => { if (user === null) return undefined const subscription = subscribeToKnowledgeDocuments( () => { void loadFirstPage(false) }, setConnected, ) return () => { void subscription.unsubscribe() } }, [loadFirstPage, user]) const submitDocumentSearch = (): void => { const normalized = documentSearch.trim() if (normalized === activeDocumentSearch) void loadFirstPage(false) else setActiveDocumentSearch(normalized) } useEffect(() => { void loadFirstPage(false) }, [activeDocumentSearch, loadFirstPage]) const loadMore = async (): Promise => { if (user === null || loading || loadingMore || !hasMore) return const targetPage = page + 1 setLoadingMore(true) try { const result = await listKnowledgeDocuments({ userId: user.id, search: activeDocumentSearch, page: targetPage, }) setDocuments((current) => mergeKnowledgeDocuments(current, result.documents)) setPage(targetPage) setTotal(result.total) setHasMore(result.hasMore) } catch (requestError) { setError(errorCode(requestError)) } finally { setLoadingMore(false) } } const importDocument = async (): Promise => { if ( user === null || session === null || importInFlight.current || indexInFlight.current || importing || indexingId !== null ) return importInFlight.current = true setImporting(true) setError(null) setNotice(null) let picked: Awaited> | null = null try { picked = await pickKnowledgeTextFile() const created = await createKnowledgeDocument({ userId: user.id, title: picked.title, fileName: picked.fileName, fileType: picked.fileType, content: picked.content, }) setDocuments((current) => mergeKnowledgeDocuments([created], current)) setTotal((current) => current + 1) setIndexingId(created.id) const controller = new AbortController() indexController.current = controller try { const indexed = await indexKnowledgeDocument({ accessToken: session.access_token, userId: user.id, document: created, signal: controller.signal, }) setDocuments((current) => current.map((document) => ( document.id === indexed.id ? indexed : document ))) setNotice(t('mobile.knowledge.importReady')) } catch (indexError) { if (errorCode(indexError) !== 'cancelled') { setError(errorCode(indexError)) setNotice(t('mobile.knowledge.importSavedPending')) } } finally { if (indexController.current === controller) indexController.current = null setIndexingId(null) } } catch (requestError) { const code = errorCode(requestError) if (code !== 'cancelled') setError(code) } finally { if (picked !== null) { try { await picked.dispose() } catch { setNotice(t('mobile.knowledge.cleanupWarning')) } } setImporting(false) importInFlight.current = false } } const retryIndex = async (document: KnowledgeDocument): Promise => { if ( user === null || session === null || document.user_id !== user.id || indexInFlight.current || indexingId !== null ) return indexInFlight.current = true setError(null) setNotice(null) setIndexingId(document.id) const controller = new AbortController() indexController.current = controller try { const indexed = await indexKnowledgeDocument({ accessToken: session.access_token, userId: user.id, document, signal: controller.signal, }) setDocuments((current) => current.map((candidate) => ( candidate.id === indexed.id ? indexed : candidate ))) setNotice(t('mobile.knowledge.indexReady')) } catch (requestError) { const code = errorCode(requestError) if (code !== 'cancelled') setError(code) } finally { if (indexController.current === controller) indexController.current = null setIndexingId(null) indexInFlight.current = false } } const confirmDelete = (document: KnowledgeDocument): void => { Alert.alert( t('mobile.knowledge.deleteTitle'), t('mobile.knowledge.deleteBody', { title: document.title }), [ { text: t('common.cancel'), style: 'cancel' }, { text: t('mobile.knowledge.delete'), style: 'destructive', onPress: () => { void performDelete(document) }, }, ], ) } const performDelete = async (document: KnowledgeDocument): Promise => { if (user === null || deletingId !== null || deletingIds.current.has(document.id)) return deletingIds.current.add(document.id) setDeletingId(document.id) setError(null) try { await deleteKnowledgeDocument(user.id, document) setDocuments((current) => current.filter((candidate) => candidate.id !== document.id)) setTotal((current) => Math.max(0, current - 1)) setResults((current) => current.filter((result) => result.documentId !== document.id)) } catch (requestError) { const code = errorCode(requestError) setError(code) if (code === 'conflict') void loadFirstPage(false) } finally { setDeletingId(null) deletingIds.current.delete(document.id) } } const runQuery = async (): Promise => { if (session === null || query.trim().length === 0 || queryInFlight.current || querying) return queryInFlight.current = true const controller = new AbortController() searchController.current = controller setQuerying(true) setError(null) setNotice(null) try { const response = await searchKnowledge({ accessToken: session.access_token, query, count: 8, signal: controller.signal, }) setResults(response) if (response.length === 0) setNotice(t('mobile.knowledge.queryEmpty')) } catch (requestError) { const code = errorCode(requestError) if (code !== 'cancelled') setError(code) } finally { if (searchController.current === controller) searchController.current = null setQuerying(false) queryInFlight.current = false } } const header = ( {t('mobile.knowledge.title')} {t('mobile.knowledge.count', { count: total })} { void importDocument() }} testID="knowledge-import" /> {!connected && user !== null && ( {t('mobile.knowledge.realtimeWaiting')} )} {t('mobile.knowledge.contractNotice')} {t('mobile.knowledge.queryTitle')} {t('mobile.knowledge.queryNotice')} { void runQuery() }} editable={!querying} placeholder={t('mobile.knowledge.queryPlaceholder')} placeholderTextColor={palette.text.muted} returnKeyType="search" maxLength={500} style={styles.input} testID="knowledge-query-input" /> { void runQuery() }} style={styles.flexButton} testID="knowledge-query-submit" /> {querying && ( searchController.current?.abort()} style={styles.cancelButton} testID="knowledge-query-cancel" /> )} {results.length > 0 && ( {t('mobile.knowledge.resultCount', { count: results.length })} {results.map((result) => ( {documentTitle(documents, result.documentId, t)} · #{result.chunkIndex + 1} {Math.round(result.similarity * 100)}% {result.content} ))} )} {error !== null && ( {knowledgeErrorMessage(error, t)} { void loadFirstPage(false) }} testID="knowledge-retry" /> )} {notice !== null && ( {notice} )} {t('mobile.knowledge.library')} ) return ( {loading && documents.length === 0 ? ( {header} {t('mobile.knowledge.loading')} ) : ( document.id} ListHeaderComponent={header} contentContainerStyle={documents.length === 0 ? styles.emptyList : styles.list} keyboardShouldPersistTaps="handled" refreshControl={( { void loadFirstPage(true) }} tintColor={palette.accent.main} colors={[palette.accent.main]} /> )} onEndReached={() => { void loadMore() }} onEndReachedThreshold={0.25} ListEmptyComponent={( {activeDocumentSearch.length > 0 ? t('mobile.knowledge.emptySearch') : t('mobile.knowledge.empty')} )} ListFooterComponent={loadingMore ? ( ) : } renderItem={({ item: document }) => { const owner = document.user_id === user?.id const indexing = indexingId === document.id const deleting = deletingId === document.id return ( {document.title} {document.file_name ?? t('mobile.knowledge.noFileName')} {t('mobile.knowledge.chunkCount', { count: document.chunk_count })} · {' '} {formatDate(new Date(document.created_at), { dateStyle: 'medium', timeStyle: 'short', })} {!owner && ( {t('mobile.knowledge.teamShared')} )} {owner && ( {!document.indexed && ( { void retryIndex(document) }} style={styles.flexButton} testID={`knowledge-index-${document.id}`} /> )} {indexing && ( indexController.current?.abort()} testID={`knowledge-index-cancel-${document.id}`} /> )} confirmDelete(document)} testID={`knowledge-delete-${document.id}`} /> )} ) }} /> )} ) } function errorCode(error: unknown): KnowledgeServiceErrorCode { return error instanceof KnowledgeServiceError ? error.code : 'server' } function knowledgeErrorMessage(code: KnowledgeServiceErrorCode, t: TFunction): string { if (code === 'auth') return t('mobile.knowledge.error.auth') if (code === 'conflict') return t('mobile.knowledge.error.conflict') if (code === 'forbidden') return t('mobile.knowledge.error.forbidden') if (code === 'index-failed') return t('mobile.knowledge.error.indexFailed') if (code === 'index-unavailable') return t('mobile.knowledge.error.indexUnavailable') if (code === 'invalid-response') return t('mobile.knowledge.error.response') if (code === 'network') return t('mobile.knowledge.error.network') if (code === 'not-found') return t('mobile.knowledge.error.notFound') if (code === 'timeout') return t('mobile.knowledge.error.timeout') if (code === 'validation') return t('mobile.knowledge.error.validation') if (code === 'cancelled') return t('mobile.knowledge.error.cancelled') return t('mobile.knowledge.error.server') } function documentTitle( documents: KnowledgeDocument[], documentId: string, t: TFunction, ): string { return documents.find((document) => document.id === documentId)?.title ?? t('mobile.knowledge.unknownDocument') } function StatusBadge({ indexed, processing, }: { indexed: boolean processing: boolean }): React.ReactElement { const { t } = useI18n() const { palette } = useMobilePreferences() const label = processing ? t('mobile.knowledge.statusProcessing') : indexed ? t('mobile.knowledge.statusReady') : t('mobile.knowledge.statusPending') const color = processing || !indexed ? palette.tag.orange : palette.tag.green return ( {label} ) } type Palette = ReturnType['palette'] const stylesStatic = StyleSheet.create({ badge: { minHeight: 30, justifyContent: 'center', paddingHorizontal: 9, borderWidth: 1, borderRadius: 999, }, }) function createStyles(palette: Palette): ReturnType { return StyleSheet.create({ container: { flex: 1, backgroundColor: palette.bg.app }, loadingWrap: { flex: 1 }, list: { paddingBottom: 100 }, emptyList: { flexGrow: 1, paddingBottom: 100 }, header: { minHeight: 92, paddingHorizontal: 20, paddingBottom: 10, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12, }, headerCopy: { flex: 1, gap: 4 }, syncBanner: { paddingHorizontal: 20, paddingVertical: 8, borderTopWidth: 1, borderBottomWidth: 1, borderColor: palette.border.subtle, backgroundColor: palette.bg.sidebar, }, contractCard: { marginHorizontal: 20, marginBottom: 12 }, contractText: { lineHeight: 20 }, searchRow: { paddingHorizontal: 20, flexDirection: 'row', gap: 8, marginBottom: 12, }, input: { flex: 1, minHeight: 48, borderWidth: 1, borderColor: palette.border.default, borderRadius: 10, paddingHorizontal: 14, backgroundColor: palette.bg.inset, color: palette.text.primary, fontSize: 14, }, searchButton: { minHeight: 48, paddingHorizontal: 12 }, queryCard: { marginHorizontal: 20, gap: 12 }, queryActions: { flexDirection: 'row', gap: 8 }, flexButton: { flex: 1 }, cancelButton: { minWidth: 92 }, results: { gap: 10, marginTop: 4 }, resultItem: { gap: 7, padding: 12, borderRadius: 10, borderWidth: 1, borderColor: palette.border.subtle, backgroundColor: palette.bg.inset, }, resultMeta: { flexDirection: 'row', justifyContent: 'space-between', gap: 10 }, resultText: { lineHeight: 20 }, errorBanner: { marginHorizontal: 20, marginTop: 12, paddingHorizontal: 12, minHeight: 56, flexDirection: 'row', alignItems: 'center', gap: 10, borderWidth: 1, borderColor: palette.tag.red, borderRadius: 10, backgroundColor: palette.bg.card, }, notice: { marginHorizontal: 20, marginTop: 12, padding: 12, borderWidth: 1, borderColor: palette.accent.main, borderRadius: 10, backgroundColor: palette.accent.dim, }, libraryLabel: { marginHorizontal: 24, marginTop: 24, marginBottom: 10, letterSpacing: 2.2 }, center: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, padding: 32 }, emptyCard: { marginHorizontal: 20, alignItems: 'center', paddingVertical: 36 }, emptyText: { textAlign: 'center' }, loadingMore: { minHeight: 72, justifyContent: 'center', alignItems: 'center' }, documentCard: { marginHorizontal: 20, marginBottom: 12, gap: 10 }, documentTop: { flexDirection: 'row', alignItems: 'flex-start', gap: 12 }, flexText: { flex: 1 }, documentActions: { flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-end', gap: 8, paddingTop: 4, borderTopWidth: 1, borderTopColor: palette.border.subtle, }, }) }