feat(release): prepare 1.1.0 candidate
This commit is contained in:
parent
5a34f66981
commit
5205dcdfa9
736 changed files with 115667 additions and 12203 deletions
720
apps/mobile-rn/src/screens/KnowledgeScreen.tsx
Normal file
720
apps/mobile-rn/src/screens/KnowledgeScreen.tsx
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
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<AbortController | null>(null)
|
||||
const indexController = useRef<AbortController | null>(null)
|
||||
const importInFlight = useRef(false)
|
||||
const indexInFlight = useRef(false)
|
||||
const queryInFlight = useRef(false)
|
||||
const deletingIds = useRef(new Set<string>())
|
||||
const [documents, setDocuments] = useState<KnowledgeDocument[]>([])
|
||||
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<KnowledgeSearchResult[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [importing, setImporting] = useState(false)
|
||||
const [indexingId, setIndexingId] = useState<string | null>(null)
|
||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
||||
const [querying, setQuerying] = useState(false)
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [error, setError] = useState<KnowledgeServiceErrorCode | null>(null)
|
||||
const [notice, setNotice] = useState<string | null>(null)
|
||||
|
||||
const loadFirstPage = useCallback(async (pullToRefresh = false): Promise<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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<ReturnType<typeof pickKnowledgeTextFile>> | 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<void> => {
|
||||
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<void> => {
|
||||
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<void> => {
|
||||
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 = (
|
||||
<View>
|
||||
<View style={[styles.header, { paddingTop: insets.top + 12 }] }>
|
||||
<View style={styles.headerCopy}>
|
||||
<ThemeText variant="title" accessibilityRole="header">
|
||||
{t('mobile.knowledge.title')}
|
||||
</ThemeText>
|
||||
<ThemeText variant="label" color="muted">
|
||||
{t('mobile.knowledge.count', { count: total })}
|
||||
</ThemeText>
|
||||
</View>
|
||||
<ThemeButton
|
||||
label={importing ? t('mobile.knowledge.importing') : t('mobile.knowledge.import')}
|
||||
disabled={importing || indexingId !== null || user === null}
|
||||
onPress={() => { void importDocument() }}
|
||||
testID="knowledge-import"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{!connected && user !== null && (
|
||||
<View style={styles.syncBanner} testID="knowledge-realtime-pending">
|
||||
<ThemeText variant="small" color="muted">
|
||||
{t('mobile.knowledge.realtimeWaiting')}
|
||||
</ThemeText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ThemeCard inset style={styles.contractCard} testID="knowledge-contract">
|
||||
<ThemeText color="secondary" style={styles.contractText}>
|
||||
{t('mobile.knowledge.contractNotice')}
|
||||
</ThemeText>
|
||||
</ThemeCard>
|
||||
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
accessibilityLabel={t('mobile.knowledge.documentSearch')}
|
||||
value={documentSearch}
|
||||
onChangeText={setDocumentSearch}
|
||||
onSubmitEditing={submitDocumentSearch}
|
||||
placeholder={t('mobile.knowledge.documentSearch')}
|
||||
placeholderTextColor={palette.text.muted}
|
||||
returnKeyType="search"
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={500}
|
||||
style={styles.input}
|
||||
testID="knowledge-document-search"
|
||||
/>
|
||||
<ThemeButton
|
||||
label={t('mobile.knowledge.searchDocuments')}
|
||||
variant="secondary"
|
||||
onPress={submitDocumentSearch}
|
||||
style={styles.searchButton}
|
||||
testID="knowledge-document-search-submit"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<ThemeCard style={styles.queryCard} testID="knowledge-query-card">
|
||||
<ThemeText variant="heading">{t('mobile.knowledge.queryTitle')}</ThemeText>
|
||||
<ThemeText variant="small" color="muted">
|
||||
{t('mobile.knowledge.queryNotice')}
|
||||
</ThemeText>
|
||||
<TextInput
|
||||
accessibilityLabel={t('mobile.knowledge.queryPlaceholder')}
|
||||
value={query}
|
||||
onChangeText={setQuery}
|
||||
onSubmitEditing={() => { void runQuery() }}
|
||||
editable={!querying}
|
||||
placeholder={t('mobile.knowledge.queryPlaceholder')}
|
||||
placeholderTextColor={palette.text.muted}
|
||||
returnKeyType="search"
|
||||
maxLength={500}
|
||||
style={styles.input}
|
||||
testID="knowledge-query-input"
|
||||
/>
|
||||
<View style={styles.queryActions}>
|
||||
<ThemeButton
|
||||
label={querying ? t('mobile.knowledge.querying') : t('mobile.knowledge.query')}
|
||||
disabled={querying || query.trim().length === 0 || session === null}
|
||||
onPress={() => { void runQuery() }}
|
||||
style={styles.flexButton}
|
||||
testID="knowledge-query-submit"
|
||||
/>
|
||||
{querying && (
|
||||
<ThemeButton
|
||||
label={t('mobile.knowledge.cancel')}
|
||||
variant="secondary"
|
||||
onPress={() => searchController.current?.abort()}
|
||||
style={styles.cancelButton}
|
||||
testID="knowledge-query-cancel"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
{results.length > 0 && (
|
||||
<View style={styles.results} accessibilityLiveRegion="polite" testID="knowledge-results">
|
||||
<ThemeText variant="label" color="accent">
|
||||
{t('mobile.knowledge.resultCount', { count: results.length })}
|
||||
</ThemeText>
|
||||
{results.map((result) => (
|
||||
<View key={result.id} style={styles.resultItem} testID={`knowledge-result-${result.id}`}>
|
||||
<View style={styles.resultMeta}>
|
||||
<ThemeText variant="label" color="muted">
|
||||
{documentTitle(documents, result.documentId, t)} · #{result.chunkIndex + 1}
|
||||
</ThemeText>
|
||||
<ThemeText variant="label" color="accent">
|
||||
{Math.round(result.similarity * 100)}%
|
||||
</ThemeText>
|
||||
</View>
|
||||
<ThemeText style={styles.resultText}>{result.content}</ThemeText>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
</ThemeCard>
|
||||
|
||||
{error !== null && (
|
||||
<View style={styles.errorBanner} accessibilityLiveRegion="assertive" testID="knowledge-error">
|
||||
<ThemeText color="danger" style={styles.flexText}>
|
||||
{knowledgeErrorMessage(error, t)}
|
||||
</ThemeText>
|
||||
<ThemeButton
|
||||
label={t('mobile.knowledge.retry')}
|
||||
variant="quiet"
|
||||
onPress={() => { void loadFirstPage(false) }}
|
||||
testID="knowledge-retry"
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{notice !== null && (
|
||||
<View style={styles.notice} accessibilityLiveRegion="polite" testID="knowledge-notice">
|
||||
<ThemeText color="secondary">{notice}</ThemeText>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ThemeText variant="label" color="muted" style={styles.libraryLabel}>
|
||||
{t('mobile.knowledge.library')}
|
||||
</ThemeText>
|
||||
</View>
|
||||
)
|
||||
|
||||
return (
|
||||
<View style={styles.container} testID="knowledge-screen">
|
||||
{loading && documents.length === 0 ? (
|
||||
<View style={styles.loadingWrap}>
|
||||
{header}
|
||||
<View style={styles.center} testID="knowledge-loading">
|
||||
<ActivityIndicator size="large" color={palette.accent.main} />
|
||||
<ThemeText color="muted">{t('mobile.knowledge.loading')}</ThemeText>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={documents}
|
||||
keyExtractor={(document) => document.id}
|
||||
ListHeaderComponent={header}
|
||||
contentContainerStyle={documents.length === 0 ? styles.emptyList : styles.list}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshControl={(
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={() => { void loadFirstPage(true) }}
|
||||
tintColor={palette.accent.main}
|
||||
colors={[palette.accent.main]}
|
||||
/>
|
||||
)}
|
||||
onEndReached={() => { void loadMore() }}
|
||||
onEndReachedThreshold={0.25}
|
||||
ListEmptyComponent={(
|
||||
<ThemeCard style={styles.emptyCard} testID="knowledge-empty">
|
||||
<ThemeText color="muted" style={styles.emptyText}>
|
||||
{activeDocumentSearch.length > 0
|
||||
? t('mobile.knowledge.emptySearch')
|
||||
: t('mobile.knowledge.empty')}
|
||||
</ThemeText>
|
||||
</ThemeCard>
|
||||
)}
|
||||
ListFooterComponent={loadingMore ? (
|
||||
<View style={styles.loadingMore} testID="knowledge-loading-more">
|
||||
<ActivityIndicator color={palette.accent.main} />
|
||||
</View>
|
||||
) : <View style={{ height: insets.bottom + 90 }} />}
|
||||
renderItem={({ item: document }) => {
|
||||
const owner = document.user_id === user?.id
|
||||
const indexing = indexingId === document.id
|
||||
const deleting = deletingId === document.id
|
||||
return (
|
||||
<ThemeCard style={styles.documentCard} testID={`knowledge-document-${document.id}`}>
|
||||
<View style={styles.documentTop}>
|
||||
<View style={styles.flexText}>
|
||||
<ThemeText numberOfLines={2}>{document.title}</ThemeText>
|
||||
<ThemeText variant="small" color="muted">
|
||||
{document.file_name ?? t('mobile.knowledge.noFileName')}
|
||||
</ThemeText>
|
||||
</View>
|
||||
<StatusBadge indexed={document.indexed} processing={indexing} />
|
||||
</View>
|
||||
<ThemeText variant="small" color="muted">
|
||||
{t('mobile.knowledge.chunkCount', { count: document.chunk_count })} · {' '}
|
||||
{formatDate(new Date(document.created_at), {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}
|
||||
</ThemeText>
|
||||
{!owner && (
|
||||
<ThemeText variant="small" color="muted">
|
||||
{t('mobile.knowledge.teamShared')}
|
||||
</ThemeText>
|
||||
)}
|
||||
{owner && (
|
||||
<View style={styles.documentActions}>
|
||||
{!document.indexed && (
|
||||
<ThemeButton
|
||||
label={indexing
|
||||
? t('mobile.knowledge.indexing')
|
||||
: t('mobile.knowledge.retryIndex')}
|
||||
variant="secondary"
|
||||
disabled={indexing || deleting || indexingId !== null}
|
||||
onPress={() => { void retryIndex(document) }}
|
||||
style={styles.flexButton}
|
||||
testID={`knowledge-index-${document.id}`}
|
||||
/>
|
||||
)}
|
||||
{indexing && (
|
||||
<ThemeButton
|
||||
label={t('mobile.knowledge.cancel')}
|
||||
variant="quiet"
|
||||
onPress={() => indexController.current?.abort()}
|
||||
testID={`knowledge-index-cancel-${document.id}`}
|
||||
/>
|
||||
)}
|
||||
<ThemeButton
|
||||
label={deleting
|
||||
? t('mobile.knowledge.deleting')
|
||||
: t('mobile.knowledge.delete')}
|
||||
variant="quiet"
|
||||
disabled={deleting || indexing}
|
||||
onPress={() => confirmDelete(document)}
|
||||
testID={`knowledge-delete-${document.id}`}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</ThemeCard>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<View style={[stylesStatic.badge, { borderColor: color }]}>
|
||||
<ThemeText variant="label" style={{ color }}>{label}</ThemeText>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
type Palette = ReturnType<typeof useMobilePreferences>['palette']
|
||||
|
||||
const stylesStatic = StyleSheet.create({
|
||||
badge: {
|
||||
minHeight: 30,
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 9,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
},
|
||||
})
|
||||
|
||||
function createStyles(palette: Palette): ReturnType<typeof StyleSheet.create> {
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue