feat(release): prepare 1.1.0 candidate

This commit is contained in:
Yun Chan 2026-08-29 18:33:45 +09:00
parent 5a34f66981
commit 5205dcdfa9
736 changed files with 115667 additions and 12203 deletions

View 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,
},
})
}