From 5c12cc2fe4045dbe94d42726999a640a8472f7d0 Mon Sep 17 00:00:00 2001 From: alichherawalla Date: Thu, 15 Oct 2026 13:30:29 +0530 Subject: [PATCH 1/5] Phone documents and knowledge bases use the shared document engine Attached files and project knowledge-base files are kept, read page by page natively (any size), indexed in SQLite FTS5 + MiniLM, and rebuilt for every question within the phone's context and reading speed. Answers cite pages as [S#] links that open the page; chips and the Documents sheet show reading progress; the knowledge-base list shows indexing and sync progress. Regenerate now uses documents too. The old chunk/cosine knowledge base, its 5 MB caps and the paste-whole-text path are gone. --- App.tsx | 4 +- .../offgridmobile/pdf/PDFExtractorModule.kt | 38 ++ ios/PDFExtractorModule.m | 6 + ios/PDFExtractorModule.swift | 39 ++ ios/Podfile.lock | 4 +- metro.config.js | 2 + package-lock.json | 2 +- package.json | 4 +- pro | 2 +- src/bootstrap/hookRegistry.ts | 8 + src/components/ChatDocumentsSheet.tsx | 206 ++++++++ src/components/ChatInput/Attachments.tsx | 37 +- src/components/ChatInput/styles.ts | 7 + .../ChatMessage/components/MessageContent.tsx | 41 +- .../ChatMessage/components/ToolMessages.tsx | 7 + src/components/ChatMessage/index.tsx | 6 + src/components/ChatMessage/types.ts | 4 +- src/components/MarkdownText.tsx | 7 +- .../knowledge/KnowledgeDocumentInfo.tsx | 201 ++++++++ src/hooks/useKnowledgeDocumentTransfers.ts | 77 +++ src/navigation/types.ts | 9 +- .../ChatScreen/ChatScreenComponents.tsx | 16 +- src/screens/ChatScreen/MessageRenderer.tsx | 5 +- src/screens/ChatScreen/index.tsx | 6 + src/screens/ChatScreen/types.ts | 21 + .../ChatScreen/useChatGenerationActions.ts | 266 ++++++---- src/screens/ChatScreen/useChatScreen.ts | 7 +- src/screens/DocumentPreviewScreen.tsx | 112 ++++- src/screens/KnowledgeBaseScreen.styles.ts | 27 +- src/screens/KnowledgeBaseScreen.tsx | 130 +++-- .../ProjectDetailKnowledgeBaseSection.tsx | 123 +++-- src/screens/ProjectDetailScreen.styles.ts | 11 +- src/screens/ProjectDetailScreen.tsx | 8 +- src/services/demoSeed.ts | 10 +- src/services/documentService.ts | 212 +------- src/services/documents/chatTurn.ts | 170 +++++++ src/services/documents/conversation.ts | 75 +++ src/services/documents/extract.ts | 34 ++ src/services/documents/index.ts | 67 +++ src/services/documents/models.ts | 29 ++ src/services/documents/progressStore.ts | 32 ++ src/services/documents/project.ts | 18 + src/services/documents/store.ts | 355 +++++++++++++ src/services/index.ts | 2 +- src/services/pdfExtractor.ts | 19 + src/services/rag/chunking.ts | 1 - src/services/rag/database.ts | 473 ++++-------------- src/services/rag/index.ts | 347 +++++++------ src/services/rag/retrieval.ts | 107 ---- src/services/rag/vectorMath.ts | 38 -- .../sync/knowledgeDocumentTransfer.ts | 108 ++++ src/services/tools/handlers.ts | 34 +- src/stores/chatStore.ts | 12 + src/stores/projectStore.ts | 4 +- src/types/index.ts | 17 + 55 files changed, 2454 insertions(+), 1153 deletions(-) create mode 100644 src/components/ChatDocumentsSheet.tsx create mode 100644 src/components/knowledge/KnowledgeDocumentInfo.tsx create mode 100644 src/hooks/useKnowledgeDocumentTransfers.ts create mode 100644 src/services/documents/chatTurn.ts create mode 100644 src/services/documents/conversation.ts create mode 100644 src/services/documents/extract.ts create mode 100644 src/services/documents/index.ts create mode 100644 src/services/documents/models.ts create mode 100644 src/services/documents/progressStore.ts create mode 100644 src/services/documents/project.ts create mode 100644 src/services/documents/store.ts delete mode 100644 src/services/rag/chunking.ts delete mode 100644 src/services/rag/retrieval.ts delete mode 100644 src/services/rag/vectorMath.ts create mode 100644 src/services/sync/knowledgeDocumentTransfer.ts diff --git a/App.tsx b/App.tsx index 32a2114b9..8e4402a40 100644 --- a/App.tsx +++ b/App.tsx @@ -16,7 +16,7 @@ import { useProExpiryRedirect, } from './src/navigation/useProExpiryRedirect'; import { useTheme } from './src/theme'; -import { hardwareService, modelManager, authService, ragService, remoteServerManager } from './src/services'; +import { hardwareService, modelManager, authService, knowledgeBase, remoteServerManager } from './src/services'; import logger from './src/utils/logger'; import { useAppStore, useAuthStore, useRemoteServerStore, useWhisperStore } from './src/stores'; import { useDebugLogsStore } from './src/stores/debugLogsStore'; @@ -342,7 +342,7 @@ function App() { } // Initialize RAG database tables - ragService.ensureReady().catch((err) => logger.error('Failed to initialize RAG service on startup', err)); + knowledgeBase.ensureReady().catch((err) => logger.error('Failed to open the knowledge base on startup', err)); try { // Register the private Pro entitlement provider before the first status diff --git a/android/app/src/main/java/ai/offgridmobile/pdf/PDFExtractorModule.kt b/android/app/src/main/java/ai/offgridmobile/pdf/PDFExtractorModule.kt index 33e7e8e6b..d1f8e2764 100644 --- a/android/app/src/main/java/ai/offgridmobile/pdf/PDFExtractorModule.kt +++ b/android/app/src/main/java/ai/offgridmobile/pdf/PDFExtractorModule.kt @@ -8,6 +8,7 @@ import ai.offgridmobile.SafePromise import io.legere.pdfiumandroid.PdfDocument import io.legere.pdfiumandroid.PdfiumCore import android.os.ParcelFileDescriptor +import com.facebook.react.bridge.Arguments import java.io.File class PDFExtractorModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { @@ -71,4 +72,41 @@ class PDFExtractorModule(reactContext: ReactApplicationContext) : ReactContextBa } }.start() } + + /** + * Text of `count` pages from `start` (0-based), one string per page, plus the page count. + * Pages stay separate so answers can cite them; nothing is capped. + */ + @ReactMethod + fun extractPages(filePath: String, start: Double, count: Double, promise: Promise) { + Thread { + try { + val file = File(filePath.removePrefix("file://")) + if (!file.exists()) { + safeReject(promise, "PDF_ERROR", "File not found: $filePath") + return@Thread + } + val core = PdfiumCore(reactApplicationContext) + val fd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) + val doc = core.newDocument(fd) + val pageCount = doc.getPageCount() + val first = maxOf(0, start.toInt()) + val last = minOf(pageCount, first + maxOf(0, count.toInt())) + val pages = Arguments.createArray() + for (i in first until last) { + val sb = StringBuilder() + extractPageText(doc, i, sb) + pages.pushString(sb.toString()) + } + doc.close() + fd.close() + val result = Arguments.createMap() + result.putInt("pageCount", pageCount) + result.putArray("pages", pages) + safeResolve(promise, result) + } catch (e: Exception) { + safeReject(promise, "PDF_ERROR", "Failed to read pages: ${e.message}", e) + } + }.start() + } } diff --git a/ios/PDFExtractorModule.m b/ios/PDFExtractorModule.m index 4b3f50255..bce4097f1 100644 --- a/ios/PDFExtractorModule.m +++ b/ios/PDFExtractorModule.m @@ -7,4 +7,10 @@ @interface RCT_EXTERN_MODULE(PDFExtractorModule, NSObject) resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(extractPages:(NSString *)filePath + start:(double)start + count:(double)count + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) + @end diff --git a/ios/PDFExtractorModule.swift b/ios/PDFExtractorModule.swift index 1d0fae248..74c517054 100644 --- a/ios/PDFExtractorModule.swift +++ b/ios/PDFExtractorModule.swift @@ -119,4 +119,43 @@ class PDFExtractorModule: NSObject { resolve(fullText) } } + + // One PDF stays open between page batches, so a long book is not re-opened for every batch. + private var openPath: String? + private var openDocument: PDFDocument? + private let documentLock = NSLock() + + private func document(at filePath: String) -> PDFDocument? { + documentLock.lock() + defer { documentLock.unlock() } + if openPath == filePath, let document = openDocument { return document } + let url = filePath.hasPrefix("file://") ? URL(string: filePath) : URL(fileURLWithPath: filePath) + guard let url = url, let document = PDFDocument(url: url) else { return nil } + openPath = filePath + openDocument = document + return document + } + + /// Text of `count` pages from `start` (0-based), one string per page, plus the page count. + /// Pages stay separate so answers can cite them; nothing is capped. + @objc + func extractPages(_ filePath: String, start: Double, count: Double, resolver resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { + DispatchQueue.global(qos: .utility).async { + guard let document = self.document(at: filePath) else { + reject("PDF_ERROR", "Could not open PDF file", nil as NSError?) + return + } + let first = max(0, Int(start)) + let last = min(document.pageCount, first + max(0, Int(count))) + var pages: [String] = [] + if first < last { + for pageIndex in first..=20" }, - "op-sqlite": {}, + "op-sqlite": { + "fts5": true + }, "overrides": { "react": "19.2.0", "linkify-it": "^5.0.2", diff --git a/pro b/pro index 28b7ef69e..e2a9c9ecb 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit 28b7ef69ecc21b1c85a4d0b92fde91bcd84b9ae1 +Subproject commit e2a9c9ecbd8dc81748fa8d2386625104b8820f19 diff --git a/src/bootstrap/hookRegistry.ts b/src/bootstrap/hookRegistry.ts index 13c10f40f..f6d8aa109 100644 --- a/src/bootstrap/hookRegistry.ts +++ b/src/bootstrap/hookRegistry.ts @@ -85,6 +85,14 @@ export const HOOKS = { /** (mutation: KnowledgeDocumentMutation) => void — the RAG owner committed * a document lifecycle change. Pro transfers or reconciles it with peers. */ syncKnowledgeDocumentMutation: 'sync.knowledgeDocumentMutation', + /** (listener: () => void) => () => void — knowledge-base files moving to or from a paired device + * changed. Pro calls it a few times a second at most; free builds register nothing. */ + syncKnowledgeTransfersSubscribe: 'sync.knowledgeTransfers.subscribe', + /** (projectId: string) => KnowledgeDocumentTransfer[] — the project's files moving now, and the + * ones that failed, so the knowledge-base list can draw them. */ + syncKnowledgeTransfersSnapshot: 'sync.knowledgeTransfers.snapshot', + /** (transfer: KnowledgeDocumentTransfer) => void — forget a failed transfer the person has read. */ + syncKnowledgeTransferDismiss: 'sync.knowledgeTransfers.dismiss', /** (text: string, timestamp: number) => void — core copied text locally. Pro records it through * the shared clipboard owner instead of waiting for a delayed native clipboard notification. */ clipboardRecordLocalText: 'clipboard.recordLocalText', diff --git a/src/components/ChatDocumentsSheet.tsx b/src/components/ChatDocumentsSheet.tsx new file mode 100644 index 000000000..0d998d852 --- /dev/null +++ b/src/components/ChatDocumentsSheet.tsx @@ -0,0 +1,206 @@ +// "Documents in this chat": every document attached anywhere in the conversation, how far each +// has been read, and a switch to leave one out of the answers. Tapping a row opens its pages. + +import React, { useEffect, useMemo, useState } from 'react'; +import { View, Text, ScrollView, TouchableOpacity } from 'react-native'; +import Icon from 'react-native-vector-icons/Feather'; +import type { StoredDocument } from '@offgrid/rag/documents'; +import { AppSheet } from './AppSheet'; +import { useTheme, useThemedStyles } from '../theme'; +import type { ThemeColors, ThemeShadows } from '../theme'; +import { TYPOGRAPHY, SPACING } from '../constants'; +import { useDocumentProgressStore } from '../services/documents/progressStore'; +import { documentStore, excludedDocuments, setDocumentExcluded } from '../services/documents/store'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import type { RootStackParamList } from '../navigation/types'; +import type { Conversation } from '../types'; + +export interface ChatDocument { + documentId: string; + name: string; +} + +interface ChatDocumentsSheetProps { + visible: boolean; + onClose: () => void; + conversationId: string; + documents: ChatDocument[]; + onOpen: (document: ChatDocument) => void; +} + +function pagesLabel(count: number): string { + return `${count.toLocaleString()} page${count === 1 ? '' : 's'}`; +} + +const PHASE_LABEL = { reading: 'Reading', ocr: 'Scanning', indexing: 'Indexing' } as const; + +export const ChatDocumentsSheet: React.FC = ({ + visible, + onClose, + conversationId, + documents, + onOpen, +}) => { + const { colors } = useTheme(); + const styles = useThemedStyles(createStyles); + const progress = useDocumentProgressStore((state) => state.byId); + const [stored, setStored] = useState>({}); + const [excluded, setExcluded] = useState>(new Set()); + + useEffect(() => { + if (!visible) return; + let current = true; + setExcluded(excludedDocuments(conversationId)); + Promise.all(documents.map((d) => documentStore.getDocument(d.documentId))) + .then((rows) => { + if (current) setStored(Object.fromEntries(documents.map((d, i) => [d.documentId, rows[i] ?? null]))); + }) + .catch(() => {}); + return () => { + current = false; + }; + }, [conversationId, documents, visible]); + + const status = (documentId: string): string => { + const live = progress[documentId]; + if (live && live.phase !== 'ready') { + if (live.phase === 'failed') return "Couldn't read"; + const percent = live.total ? Math.min(99, Math.round((live.done / live.total) * 100)) : 0; + return `${PHASE_LABEL[live.phase]} ${percent}%`; + } + const row = stored[documentId]; + if (row?.state === 'failed') return "Couldn't read"; + const pages = live?.pageCount ?? row?.pageCount; + return pages ? pagesLabel(pages) : 'Waiting'; + }; + + const toggle = (documentId: string) => { + const leaveOut = !excluded.has(documentId); + setDocumentExcluded(conversationId, documentId, leaveOut); + setExcluded(excludedDocuments(conversationId)); + }; + + return ( + + + {documents.map((document) => { + const out = excluded.has(document.documentId); + return ( + + onOpen(document)} + accessibilityRole="button" + accessibilityLabel={`Open ${document.name}`} + > + + + {document.name} + + + {out ? 'Left out' : status(document.documentId)} + + + toggle(document.documentId)} + accessibilityRole="switch" + accessibilityState={{ checked: !out }} + accessibilityLabel={out ? `Use ${document.name} in answers` : `Leave ${document.name} out of answers`} + testID={`chat-document-toggle-${document.documentId}`} + > + + + + ); + })} + + + ); +}; + +const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ + list: { + flexGrow: 0, + }, + listContent: { + paddingHorizontal: SPACING.lg, + paddingTop: SPACING.sm, + paddingBottom: SPACING.xxl, + }, + row: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + rowMain: { + flex: 1, + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.sm, + paddingVertical: SPACING.md, + }, + name: { + ...TYPOGRAPHY.body, + color: colors.text, + flexShrink: 1, + }, + nameOut: { + color: colors.textMuted, + }, + meta: { + ...TYPOGRAPHY.labelSmall, + color: colors.textMuted, + marginLeft: 'auto' as const, + }, + toggle: { + padding: SPACING.sm, + marginLeft: SPACING.xs, + }, +}); + +type RootNavigation = NativeStackNavigationProp; + +/** Open a document's stored pages, at `page` when given (a tapped citation). */ +export function openDocumentPage( + navigation: RootNavigation, + document: { documentId: string; name: string; page?: number }, +): void { + navigation.navigate('DocumentPreview', { + filePath: '', + fileName: document.name, + fileSize: 0, + documentId: document.documentId, + ...(document.page ? { page: document.page } : {}), + }); +} + +/** The chat's Documents button and sheet: every document attached anywhere in the conversation. */ +export function useChatDocumentsSheet(conversation: Conversation | null | undefined, navigation: RootNavigation) { + const [visible, setVisible] = useState(false); + const messages = conversation?.messages; + const documents = useMemo(() => { + const byId = new Map(); + for (const message of messages ?? []) { + for (const attachment of message.attachments ?? []) { + if (attachment.type === 'document' && attachment.documentId && !byId.has(attachment.documentId)) + byId.set(attachment.documentId, { documentId: attachment.documentId, name: attachment.fileName ?? 'Document' }); + } + } + return [...byId.values()]; + }, [messages]); + const sheet = conversation ? ( + setVisible(false)} + conversationId={conversation.id} + documents={documents} + onOpen={(document) => { + setVisible(false); + openDocumentPage(navigation, document); + }} + /> + ) : null; + return { count: documents.length, open: () => setVisible(true), sheet }; +} diff --git a/src/components/ChatInput/Attachments.tsx b/src/components/ChatInput/Attachments.tsx index 842cbfe1e..766a7c97c 100644 --- a/src/components/ChatInput/Attachments.tsx +++ b/src/components/ChatInput/Attachments.tsx @@ -24,6 +24,7 @@ import Icon from 'react-native-vector-icons/Feather'; import { useTheme, useThemedStyles } from '../../theme'; import { MediaAttachment } from '../../types'; import { documentService } from '../../services/documentService'; +import { useDocumentProgressStore } from '../../services/documents/progressStore'; import { audioSessionManager } from '../../services/audioSessionManager'; import { AlertState, showAlert, hideAlert } from '../CustomAlert'; import { createStyles } from './styles'; @@ -154,11 +155,9 @@ export function useAttachments(setAlertState: (state: AlertState) => void) { ); return; } - const attachment = await documentService.processDocumentFromPath( - file.uri, - fileName, - ); - if (attachment) setAttachments(prev => [...prev, attachment]); + // Kept and indexed in the background: any size, every page, usable in later turns too. + const attachment = await documentService.attachDocument(file.uri, fileName); + setAttachments(prev => [...prev, attachment]); } catch (pickError: any) { if ( isErrorWithCode(pickError) && @@ -231,6 +230,27 @@ interface AttachmentPreviewProps { onImagePress?: (uri: string) => void; } +const PHASE_LABEL = { reading: 'Reading', ocr: 'Scanning', indexing: 'Indexing' } as const; + +/** Background reading progress under a document chip: "Reading 40%", then "1,204 pages". */ +const DocumentStatus: React.FC<{ documentId: string; style: any }> = ({ documentId, style }) => { + const progress = useDocumentProgressStore(state => state.byId[documentId]); + if (!progress) return null; + let label: string; + if (progress.phase === 'failed') label = "Couldn't read"; + else if (progress.phase === 'ready') + label = `${progress.pageCount.toLocaleString()} page${progress.pageCount === 1 ? '' : 's'}`; + else { + const percent = progress.total ? Math.min(99, Math.round((progress.done / progress.total) * 100)) : 0; + label = `${PHASE_LABEL[progress.phase]} ${percent}%`; + } + return ( + + {label} + + ); +}; + export const AttachmentPreview: React.FC = ({ attachments, onRemove, @@ -282,10 +302,13 @@ export const AttachmentPreview: React.FC = ({ testID={`document-preview-${attachment.id}`} style={styles.documentPreview} > - - + + {attachment.fileName || 'Document'} + {attachment.documentId && ( + + )} )} ({ textAlign: 'center' as const, marginTop: 4, }, + documentStatus: { + fontSize: 9, + fontFamily: FONTS.mono, + color: colors.primary, + textAlign: 'center' as const, + marginTop: 2, + }, removeAttachment: { position: 'absolute' as const, top: 2, diff --git a/src/components/ChatMessage/components/MessageContent.tsx b/src/components/ChatMessage/components/MessageContent.tsx index e622e5c10..36852d2f1 100644 --- a/src/components/ChatMessage/components/MessageContent.tsx +++ b/src/components/ChatMessage/components/MessageContent.tsx @@ -1,10 +1,27 @@ -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import { View, Text } from 'react-native'; +import { splitCitations } from '@offgrid/rag/documents'; import { ThinkingIndicator } from '../../ThinkingIndicator'; import { MarkdownText } from '../../MarkdownText'; import { BlinkingCursor } from './BlinkingCursor'; import { ThinkingBlock } from './ThinkingBlock'; import type { ParsedContent } from '../types'; +import type { DocumentCitation } from '../../../types'; + +const CITATION_SCHEME = 'offgrid-doc:'; + +/** `[S#]` tags become page links ("p. 214") that open the cited page; unknown tags are dropped. */ +function citedMarkdown(text: string, sources: DocumentCitation[]): string { + return splitCitations(text) + .map((segment) => { + if (segment.kind === 'text') return segment.text; + const source = sources[segment.index - 1]; + if (!source) return ''; + const label = (source.label ?? `p. ${source.page}`).replace(/[[\]]/g, ''); + return `[${label}](${CITATION_SCHEME}${segment.index})`; + }) + .join(''); +} interface MessageContentProps { isUser: boolean; @@ -15,6 +32,9 @@ interface MessageContentProps { showThinking: boolean; onToggleThinking: () => void; styles: any; + /** Passages the answer may cite as [S#]. */ + documentSources?: DocumentCitation[]; + onCitationPress?: (source: DocumentCitation) => void; } export function MessageContent({ @@ -26,7 +46,24 @@ export function MessageContent({ showThinking, onToggleThinking, styles, + documentSources, + onCitationPress, }: Readonly) { + const response = parsedContent.response; + const answer = useMemo( + () => (!isUser && response && documentSources?.length ? citedMarkdown(response, documentSources) : response), + [documentSources, isUser, response], + ); + const handleAppLink = useCallback( + (url: string) => { + if (!url.startsWith(CITATION_SCHEME)) return false; + const source = documentSources?.[Number(url.slice(CITATION_SCHEME.length)) - 1]; + if (source) onCitationPress?.(source); + return true; + }, + [documentSources, onCitationPress], + ); + if (isThinking) { return ( @@ -101,7 +138,7 @@ export function MessageContent({ } return ( - {parsedContent.response} + {answer} {isStreaming && } ); diff --git a/src/components/ChatMessage/components/ToolMessages.tsx b/src/components/ChatMessage/components/ToolMessages.tsx index 2836db706..3a562ecff 100644 --- a/src/components/ChatMessage/components/ToolMessages.tsx +++ b/src/components/ChatMessage/components/ToolMessages.tsx @@ -9,6 +9,7 @@ import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; +import { parseSearchBlock } from '@offgrid/rag/documents'; import { useTheme } from '../../../theme'; import { useAccordionExpanded, useAccordionStore } from '../../../stores'; import { SLOTS, useSlot } from '../../../bootstrap/slotRegistry'; @@ -49,6 +50,12 @@ function getToolLabel(toolName?: string, content?: string): string { if (queryMatch) return `Searched: "${queryMatch[1]}" (no results)`; return 'Web search result'; } + case 'search_knowledge_base': { + const passages = content ? parseSearchBlock(content) : []; + if (!passages.length) return 'Searched documents (no passages)'; + const documents = new Set(passages.map(passage => passage.document)).size; + return `Found ${passages.length} passage${passages.length === 1 ? '' : 's'} in ${documents} document${documents === 1 ? '' : 's'}`; + } case 'calculator': return content || 'Calculated'; case 'get_current_datetime': diff --git a/src/components/ChatMessage/index.tsx b/src/components/ChatMessage/index.tsx index c8742a5bc..ccec94833 100644 --- a/src/components/ChatMessage/index.tsx +++ b/src/components/ChatMessage/index.tsx @@ -183,6 +183,7 @@ interface MessageBubbleProps { hideProse?: boolean; metaExtra?: React.ReactNode; onImagePress?: (uri: string) => void; + onCitationPress?: ChatMessageProps['onCitationPress']; onToggleThinking: () => void; onToggleSupportingContext: () => void; onLongPress: () => void; @@ -206,6 +207,7 @@ const MessageBubble: React.FC = ({ hideProse, metaExtra, onImagePress, + onCitationPress, onToggleThinking, onToggleSupportingContext, onLongPress, @@ -334,6 +336,8 @@ const MessageBubble: React.FC = ({ showThinking={showThinking} onToggleThinking={onToggleThinking} styles={styles} + documentSources={message.documentSources} + onCitationPress={onCitationPress} /> )} @@ -385,6 +389,7 @@ export const ChatMessage: React.FC = ({ supportingContext, isStreaming: isStreamingProp, onImagePress, + onCitationPress, onCopy, onRetry, onEdit, @@ -570,6 +575,7 @@ export const ChatMessage: React.FC = ({ hideProse={hideProse} metaExtra={metaExtra} onImagePress={onImagePress} + onCitationPress={onCitationPress} onToggleThinking={() => setShowThinking(!showThinking)} onToggleSupportingContext={() => setShowSupportingContext(!showSupportingContext) diff --git a/src/components/ChatMessage/types.ts b/src/components/ChatMessage/types.ts index 38b48c6cb..d6dd808d5 100644 --- a/src/components/ChatMessage/types.ts +++ b/src/components/ChatMessage/types.ts @@ -1,4 +1,4 @@ -import { MediaAttachment, Message } from '../../types'; +import { DocumentCitation, MediaAttachment, Message } from '../../types'; export interface ChatMessageProps { /** @@ -14,6 +14,8 @@ export interface ChatMessageProps { supportingContext?: Message; isStreaming?: boolean; onImagePress?: (uri: string) => void; + /** A page citation in an answer was tapped. */ + onCitationPress?: (source: DocumentCitation) => void; onCopy?: (content: string) => void; onRetry?: (message: Message) => void; onEdit?: (message: Message, newContent: string) => void; diff --git a/src/components/MarkdownText.tsx b/src/components/MarkdownText.tsx index 51327390e..dc03da250 100644 --- a/src/components/MarkdownText.tsx +++ b/src/components/MarkdownText.tsx @@ -79,9 +79,11 @@ interface MarkdownTextProps { children: string; dimmed?: boolean; compact?: boolean; + /** Handles app links (e.g. document citations) before the external-URL fallback. */ + onAppLink?: (url: string) => boolean; } -export function MarkdownText({ children, dimmed, compact }: MarkdownTextProps) { +export function MarkdownText({ children, dimmed, compact, onAppLink }: MarkdownTextProps) { const { colors } = useTheme(); const markdownStyles = useMemo( () => createMarkdownStyles(colors, dimmed, compact), @@ -89,10 +91,11 @@ export function MarkdownText({ children, dimmed, compact }: MarkdownTextProps) { ); const handleLinkPress = useCallback((url: string) => { + if (onAppLink?.(url)) return false; const safeUrl = safeChatExternalUrl(url); if (safeUrl) void Linking.openURL(safeUrl); return false; - }, []); + }, [onAppLink]); const processed = useMemo(() => preprocessMarkdown(children), [children]); const rules = useMemo( diff --git a/src/components/knowledge/KnowledgeDocumentInfo.tsx b/src/components/knowledge/KnowledgeDocumentInfo.tsx new file mode 100644 index 000000000..a9d86b41f --- /dev/null +++ b/src/components/knowledge/KnowledgeDocumentInfo.tsx @@ -0,0 +1,201 @@ +// A knowledge-base file's name and where its reading stands, on one line: "Reading 40%", +// "Indexing 120/480", "1,204 pages" or "Couldn't read", with a thin bar while it is not ready. +// Live progress comes from the document engine; after a restart the stored state fills in. +// A copy moving to or from a paired device speaks once the file's own reading is done: +// "Sending to Mac · 40%" or "Couldn't sync to Mac: file too large". + +import React, { useEffect, useState } from 'react'; +import { Text, View } from 'react-native'; +import Animated, { useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import type { DocumentProgress, StoredDocument } from '@offgrid/rag/documents'; +import { useThemedStyles } from '../../theme'; +import type { ThemeColors } from '../../theme'; +import { SPACING, TYPOGRAPHY } from '../../constants'; +import { useDocumentProgressStore } from '../../services/documents/progressStore'; +import { documentStore } from '../../services/documents/store'; +import { + knowledgeDocumentTransferFraction, + knowledgeDocumentTransferLabel, + type KnowledgeDocumentTransfer, +} from '../../services/sync/knowledgeDocumentTransfer'; + +const READING_LABEL = { reading: 'Reading', ocr: 'Scanning' } as const; + +function formatFileSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +interface ReadingStatus { + label: string; + /** 0–1 while the file is not ready; null when there is nothing to show a bar for. */ + fraction: number | null; + failed: boolean; +} + +function readingStatus( + live: DocumentProgress | undefined, + stored: StoredDocument | null | undefined, + hasDocument: boolean, +): ReadingStatus { + if (!hasDocument || live?.phase === 'failed' || (!live && stored?.state === 'failed')) { + return { label: "Couldn't read", fraction: null, failed: true }; + } + if (live && live.phase !== 'ready') { + const fraction = live.total ? Math.min(1, live.done / live.total) : 0; + if (live.phase === 'indexing') { + return { + label: live.total + ? `Indexing ${live.done.toLocaleString()}/${live.total.toLocaleString()}` + : 'Indexing', + fraction, + failed: false, + }; + } + return { + label: `${READING_LABEL[live.phase]} ${Math.min(99, Math.round(fraction * 100))}%`, + fraction, + failed: false, + }; + } + const ready = live?.phase === 'ready' || stored?.state === 'ready'; + const pages = live?.pageCount ?? stored?.pageCount ?? 0; + if (ready) { + return { label: `${pages.toLocaleString()} page${pages === 1 ? '' : 's'}`, fraction: null, failed: false }; + } + return { label: 'Waiting', fraction: 0, failed: false }; +} + +/** A thin bar that eases to its value; nothing when there is no value to show. */ +const ProgressLine: React.FC<{ fraction: number | null }> = ({ fraction }) => { + const styles = useThemedStyles(createStyles); + const width = useSharedValue(0); + useEffect(() => { + width.value = withTiming((fraction ?? 0) * 100, { duration: 250 }); + }, [fraction, width]); + const fillStyle = useAnimatedStyle(() => ({ width: `${width.value}%` })); + if (fraction === null) return null; + return ( + + + + ); +}; + +/** A file arriving from a paired device before this phone lists it: name, where it stands, a bar. */ +export const KnowledgeTransferInfo: React.FC<{ + transfer: KnowledgeDocumentTransfer; + testID?: string; +}> = ({ transfer, testID }) => { + const styles = useThemedStyles(createStyles); + const failed = transfer.status === 'failed'; + return ( + + + + {transfer.fileName} + + + {knowledgeDocumentTransferLabel(transfer)} + + + + + ); +}; + +export const KnowledgeDocumentInfo: React.FC<{ + name: string; + size: number; + /** Null for a file added before documents had ids whose file is gone. */ + documentId: string | null; + /** A copy of this file moving to or from a paired device. */ + transfer?: KnowledgeDocumentTransfer; + testID?: string; +}> = ({ name, size, documentId, transfer, testID }) => { + const styles = useThemedStyles(createStyles); + const live = useDocumentProgressStore((state) => (documentId ? state.byId[documentId] : undefined)); + const [stored, setStored] = useState(undefined); + + useEffect(() => { + if (!documentId) return; + let current = true; + documentStore + .getDocument(documentId) + .then((document) => { + if (current) setStored(document); + }) + .catch(() => {}); + return () => { + current = false; + }; + }, [documentId]); + + const status = readingStatus(live, stored, !!documentId); + // The file's own reading speaks first; a transfer only once the file is ready here. + const shownTransfer = status.failed || status.fraction !== null ? undefined : transfer; + const label = shownTransfer + ? knowledgeDocumentTransferLabel(shownTransfer) + : `${formatFileSize(size)} · ${status.label}`; + const failed = shownTransfer ? shownTransfer.status === 'failed' : status.failed; + const fraction = shownTransfer + ? shownTransfer.status === 'active' + ? knowledgeDocumentTransferFraction(shownTransfer) + : null + : status.fraction; + + return ( + + + + {name} + + + {label} + + + + + ); +}; + +const createStyles = (colors: ThemeColors) => ({ + line: { + flexDirection: 'row' as const, + alignItems: 'center' as const, + gap: SPACING.sm, + }, + name: { + ...TYPOGRAPHY.body, + color: colors.text, + flexShrink: 1, + }, + meta: { + ...TYPOGRAPHY.labelSmall, + color: colors.textMuted, + marginLeft: 'auto' as const, + }, + /** A transfer line can be long ("Couldn't sync to iPhone: file too large"); it shares the row. */ + metaTransfer: { + flexShrink: 1, + }, + metaFailed: { + color: colors.error, + }, + track: { + height: 2, + marginTop: SPACING.xs, + borderRadius: 1, + backgroundColor: colors.border, + overflow: 'hidden' as const, + }, + fill: { + height: 2, + borderRadius: 1, + backgroundColor: colors.primary, + }, +}); diff --git a/src/hooks/useKnowledgeDocumentTransfers.ts b/src/hooks/useKnowledgeDocumentTransfers.ts new file mode 100644 index 000000000..8381ca9cb --- /dev/null +++ b/src/hooks/useKnowledgeDocumentTransfers.ts @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { callHook, HOOKS } from '../bootstrap/hookRegistry'; +import { + knowledgeDocumentTransferKey, + type KnowledgeDocumentTransfer, +} from '../services/sync/knowledgeDocumentTransfer'; + +const NO_TRANSFERS: KnowledgeDocumentTransfer[] = []; + +function readTransfers(projectId: string): KnowledgeDocumentTransfer[] { + return ( + callHook( + HOOKS.syncKnowledgeTransfersSnapshot, + projectId, + ) ?? NO_TRANSFERS + ); +} + +/** + * Knowledge-base files of one project moving to or from a paired device. + * + * `onReceived` runs when an arriving file's transfer row goes away - the file is in the knowledge + * base now, so the list should reload to show its own row. Free builds have no sync: the list is + * always empty and nothing is called. + */ +export function useKnowledgeDocumentTransfers( + projectId: string, + onReceived?: () => void, +): { + transfers: KnowledgeDocumentTransfer[]; + dismiss: (transfer: KnowledgeDocumentTransfer) => void; +} { + const [transfers, setTransfers] = useState(() => readTransfers(projectId)); + const onReceivedRef = useRef(onReceived); + onReceivedRef.current = onReceived; + + useEffect(() => { + let receiving = new Set(); + let shown = ''; + const refresh = (): void => { + const next = readTransfers(projectId); + const nextReceiving = new Set( + next + .filter(transfer => transfer.direction === 'receive') + .map(knowledgeDocumentTransferKey), + ); + const landed = [...receiving].some(key => !nextReceiving.has(key)); + receiving = nextReceiving; + // Progress ticks for other projects reach this listener too; only redraw on a real change. + const signature = JSON.stringify(next); + if (signature !== shown) { + shown = signature; + setTransfers(next); + } + if (landed) onReceivedRef.current?.(); + }; + refresh(); + const unsubscribe = callHook<() => void>( + HOOKS.syncKnowledgeTransfersSubscribe, + refresh, + ); + return () => unsubscribe?.(); + }, [projectId]); + + const dismiss = useCallback((transfer: KnowledgeDocumentTransfer) => { + callHook(HOOKS.syncKnowledgeTransferDismiss, transfer); + setTransfers(current => + current.filter( + item => + knowledgeDocumentTransferKey(item) !== + knowledgeDocumentTransferKey(transfer), + ), + ); + }, []); + + return { transfers, dismiss }; +} diff --git a/src/navigation/types.ts b/src/navigation/types.ts index 678687011..cbd4319e2 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -12,7 +12,14 @@ export type RootStackParamList = { ProjectEdit: { projectId?: string }; ProjectChats: { projectId: string }; KnowledgeBase: { projectId: string }; - DocumentPreview: { filePath: string; fileName: string; fileSize: number }; + DocumentPreview: { + filePath: string; + fileName: string; + fileSize: number; + /** An attached document's index: show its stored pages, opened at `page`. */ + documentId?: string; + page?: number; + }; // Former SettingsStack ModelSettings: undefined; RemoteServers: undefined; diff --git a/src/screens/ChatScreen/ChatScreenComponents.tsx b/src/screens/ChatScreen/ChatScreenComponents.tsx index b5206efa0..677282bf3 100644 --- a/src/screens/ChatScreen/ChatScreenComponents.tsx +++ b/src/screens/ChatScreen/ChatScreenComponents.tsx @@ -82,7 +82,10 @@ export const ChatHeader: React.FC<{ setShowSettingsPanel: (v: boolean) => void; setShowProjectSelector: (v: boolean) => void; isRemote?: boolean; -}> = ({ styles, colors, activeConversation, activeProject, navigation, onOpenModels, setShowSettingsPanel, setShowProjectSelector, isRemote }) => ( + /** Documents attached in this chat; the Documents button shows only when there are some. */ + documentCount?: number; + onOpenDocuments?: () => void; +}> = ({ styles, colors, activeConversation, activeProject, navigation, onOpenModels, setShowSettingsPanel, setShowProjectSelector, isRemote, documentCount, onOpenDocuments }) => ( {/* Pro-only Voice mode sits beside the conversation filters. Empty in free builds. */} {(() => { const ModeToggle = getSlot(SLOTS.chatInputModeToggle); return ModeToggle ? : null; })()} + {!!documentCount && onOpenDocuments && ( + + + + )} setShowSettingsPanel(true)} testID="chat-settings-icon"> diff --git a/src/screens/ChatScreen/MessageRenderer.tsx b/src/screens/ChatScreen/MessageRenderer.tsx index cceb0365c..adecbc4fc 100644 --- a/src/screens/ChatScreen/MessageRenderer.tsx +++ b/src/screens/ChatScreen/MessageRenderer.tsx @@ -4,7 +4,7 @@ import { ChatMessage } from '../../components'; import { ThinkingIndicator } from '../../components/ThinkingIndicator'; import { SPACING } from '../../constants'; import { parseModelOutput, prepareMessageForSpeech } from '../../utils/messageContent'; -import { Message } from '../../types'; +import { DocumentCitation, Message } from '../../types'; import { useChatStore, useUiModeStore } from '../../stores'; import { getSlot, SLOTS } from '../../bootstrap/slotRegistry'; import { ChatMessageItem } from './useChatScreen'; @@ -27,6 +27,7 @@ type MessageRendererProps = { ) => Promise; onGenerateImage: (prompt: string) => void; onImagePress: (uri: string) => void; + onCitationPress?: (source: DocumentCitation) => void; }; const MessageRendererInner: React.FC = props => { @@ -45,6 +46,7 @@ const MessageRendererInner: React.FC = props => { onTranscribeAgain, onGenerateImage, onImagePress, + onCitationPress, } = props; const interfaceMode = useUiModeStore(s => s.interfaceMode); @@ -117,6 +119,7 @@ const MessageRendererInner: React.FC = props => { onTranscribeAgain={onTranscribeAgain} onGenerateImage={onGenerateImage} onImagePress={onImagePress} + onCitationPress={onCitationPress} canGenerateImage={imageModelLoaded && !isStreaming && !isGeneratingImage} showGenerationDetails={showGenerationDetails} animateEntry={animateEntry} diff --git a/src/screens/ChatScreen/index.tsx b/src/screens/ChatScreen/index.tsx index 14c6a8632..65a836748 100644 --- a/src/screens/ChatScreen/index.tsx +++ b/src/screens/ChatScreen/index.tsx @@ -25,6 +25,7 @@ import { createStyles } from './styles'; import { useChatScreen } from './useChatScreen'; import { MessageRenderer } from './MessageRenderer'; import { NoModelScreen, ChatHeader } from './ChatScreenComponents'; +import { openDocumentPage, useChatDocumentsSheet } from '../../components/ChatDocumentsSheet'; import { ChatModalSection } from './ChatModalSection'; import { ChatMessageArea } from './ChatMessageArea'; import { @@ -66,6 +67,7 @@ export const ChatScreen: React.FC = () => { 'text', ); const [whisperOpen, setWhisperOpen] = useState(false); + const documents = useChatDocumentsSheet(chat.activeConversation, rootNavigation); const [voiceOpen, setVoiceOpen] = useState(false); const returnToModelsRef = useRef(false); const reopenModelsAfterChildClose = () => { @@ -257,6 +259,7 @@ export const ChatScreen: React.FC = () => { onTranscribeAgain={chat.handleTranscribeAgain} onGenerateImage={chat.handleGenerateImageFromMessage} onImagePress={chat.handleImagePress} + onCitationPress={source => openDocumentPage(rootNavigation, source)} /> ); @@ -283,7 +286,10 @@ export const ChatScreen: React.FC = () => { setShowSettingsPanel={chat.setShowSettingsPanel} setShowProjectSelector={chat.setShowProjectSelector} isRemote={chat.activeModelInfo?.isRemote} + documentCount={documents.count} + onOpenDocuments={documents.open} /> + {documents.sheet} setModelsManagerOpen(false)} diff --git a/src/screens/ChatScreen/types.ts b/src/screens/ChatScreen/types.ts index 55232fd89..b7029209a 100644 --- a/src/screens/ChatScreen/types.ts +++ b/src/screens/ChatScreen/types.ts @@ -305,6 +305,8 @@ export type StreamingState = { isModelLoading?: boolean; loadingModelName?: string; isGeneratingForThisConversation?: boolean; + /** What the turn is doing with its documents before the model starts ("Searching 3 documents…"). */ + documentStatus?: string; /** Live previews from paired devices. Empty in free builds and when nothing is generating. */ remotePreviews?: readonly RemoteStreamItem[]; /** This device's mesh id, so a peer's runtime notices can be told apart from its own. */ @@ -453,6 +455,25 @@ function localDisplayMessages( }, ]; } + // The turn is opening, searching or ranking its documents: say so in the same loader row. + if ( + streaming.documentStatus && + (streaming.isGeneratingForThisConversation || isStreamingForThisConversation) && + !streamingMessage && + !streamingReasoningContent && + !streaming.hasStreamingText + ) { + return [ + ...allMessages, + { + id: 'thinking', + role: 'assistant' as const, + content: streaming.documentStatus, + timestamp: Date.now(), + isThinking: true, + }, + ]; + } // The tool loop stays in the thinking phase until prose begins. Once reasoning // arrives, render that live stream instead of keeping the empty loader on top. if ( diff --git a/src/screens/ChatScreen/useChatGenerationActions.ts b/src/screens/ChatScreen/useChatGenerationActions.ts index e4f42e1cf..06b1c30e1 100644 --- a/src/screens/ChatScreen/useChatGenerationActions.ts +++ b/src/screens/ChatScreen/useChatGenerationActions.ts @@ -11,8 +11,6 @@ import { ImageGenerationState, buildToolSystemPromptHint, contextCompactionService, - ragService, - retrievalService, } from '../../services'; import { getToolExtensions } from '../../services/tools/extensions'; import { @@ -28,7 +26,6 @@ import { abortPreload } from '../../services/modelPreloader'; import { modelResidencyManager } from '../../services/modelResidency'; import { reportModelFailure } from '../../services/modelFailureHandler'; import { remoteToolCapabilityIssue } from '../../services/toolCapabilityPreflight'; -import { embeddingService } from '../../services/rag/embedding'; import { useChatStore, useProjectStore, @@ -37,6 +34,7 @@ import { } from '../../stores'; import { callHook, HOOKS } from '../../bootstrap/hookRegistry'; import { + DocumentCitation, Message, MediaAttachment, Project, @@ -48,6 +46,13 @@ import { import logger from '../../utils/logger'; import { deleteChatImages, imagesNotDeletedAlert } from '../../services/chatImageCleanup'; import { ModelReadyOutcome, ensureReadyOrAlert } from './modelReadiness'; +import { describeTurnStatus } from '@offgrid/rag/documents'; +import { + prepareTurnDocuments, + turnSources, + type TurnDocuments, +} from '../../services/documents/chatTurn'; +import { useDocumentTurnStatusStore } from '../../services/documents/progressStore'; type SetState = Dispatch>; const FALLBACK_RECENT_MESSAGE_COUNT = 2; @@ -168,21 +173,35 @@ function blockedImageForNonVisionModel( ); return true; } -function appendAttachmentText( - text: string, - attachments?: MediaAttachment[], -): string { - if (!attachments) return text; - return attachments - .filter(a => a.type === 'document' && a.textContent) - .reduce( - (acc, doc) => - `${acc}\n\n---\n📄 **Attached Document: ${ - doc.fileName || 'document' - }**\n\`\`\`\n${doc.textContent}\n\`\`\`\n---`, - text, - ); +/** The model said the request did not fit its context window. */ +function isContextOverflowError(error: unknown): boolean { + const msg = (error as { message?: string } | null)?.message || String(error ?? ''); + return ( + contextCompactionService.isContextFullError(error) || + msg.includes('too long') || + msg.includes('Exceeding the maximum number of tokens') || + msg.includes('Input token ids') + ); } + +/** The answer's [S#] tags resolve against the passages it was given: keep them on the reply. */ +function attachDocumentSources(conversationId: string, sources: DocumentCitation[]): void { + if (!sources.length) return; + useChatStore.setState(state => ({ + conversations: state.conversations.map(conversation => { + if (conversation.id !== conversationId) return conversation; + const lastIndex = conversation.messages.map(message => message.role).lastIndexOf('assistant'); + if (lastIndex < 0) return conversation; + return { + ...conversation, + messages: conversation.messages.map((message, index) => + index === lastIndex ? { ...message, documentSources: sources } : message, + ), + }; + }), + })); +} + function buildMessagesForContext( conversationId: string, messageText: string, @@ -591,39 +610,55 @@ async function generateWithCompactionRetry( } return turnInterrupted; } -async function injectRagContext( - projectId: string | undefined, - query: string, - prompt: string, -): Promise { - if (!projectId) return prompt; - try { - const docs = await ragService.getDocumentsByProject(projectId); - const enabledDocs = docs.filter( - (d: import('../../services/rag').RagDocument) => d.enabled, - ); - if (enabledDocs.length === 0) return prompt; - // Warm up embedding model in background (non-blocking) - if (!embeddingService.isLoaded()) { - embeddingService - .load() - .catch(err => logger.error('[RAG] Embedding warmup failed', err)); - } - const docList = enabledDocs - .map((d: import('../../services/rag').RagDocument) => `- ${d.name}`) - .join('\n'); - let kbPrompt = `\n\nYou have a knowledge base with these documents:\n${docList}`; - kbPrompt += - '\nUse the search_knowledge_base tool to look up specific information from these documents.'; - const r = await ragService.searchProject(projectId, query); - if (r.chunks.length > 0) { - kbPrompt += `\n\n${retrievalService.formatForPrompt(r)}`; +/** + * Run a text turn with its documents: the project's knowledge base and every document in the chat, + * rebuilt for this question (whole when it fits the time this phone takes to read it, otherwise + * ranked passages per file). The status line says what the turn is doing until the model starts. + * Token estimates can run short of the real tokenizer, so a prompt that does not fit is retried once + * with less document text. Send and resend both run through here, so both answer from the same + * documents and both keep their [S#] links. + */ +async function generateWithTurnDocuments(opts: { + conversationId: string; + projectId?: string; + question: string; + systemPrompt: string; + isRemote: boolean; + generate: (documents: TurnDocuments | null) => Promise; +}): Promise { + const status = useDocumentTurnStatusStore.getState(); + const run = async (documentScale?: number): Promise => { + let documents: TurnDocuments | null; + try { + documents = await prepareTurnDocuments({ + conversationId: opts.conversationId, + ...(opts.projectId ? { projectId: opts.projectId } : {}), + question: opts.question, + systemPrompt: opts.systemPrompt, + contextLength: + useAppStore.getState().settings.contextLength || APP_CONFIG.maxContextLength, + isRemote: opts.isRemote, + ...(documentScale ? { documentScale } : {}), + onStatus: turnStatus => + status.set(opts.conversationId, describeTurnStatus(turnStatus)), + }); + } catch (error) { + logger.warn('[ChatGen] Documents unavailable for this turn:', error); + documents = null; + } finally { + status.set(opts.conversationId, null); } - return prompt + kbPrompt; - } catch (err) { - logger.error('[RAG] Context injection failed', err); + return opts.generate(documents); + }; + let stopped: boolean; + try { + stopped = await run(); + } catch (error) { + if (!turnSources(opts.conversationId).length || !isContextOverflowError(error)) throw error; + stopped = await run(0.75); } - return prompt; + attachDocumentSources(opts.conversationId, turnSources(opts.conversationId)); + return stopped; } /** Gemma 4 E2B/E4B need <|think|> prepended to activate thinking mode — both llama.cpp and LiteRT. * The engine-specific decision lives in engines.wantsLeadingThinkToken (the seam), not here. */ @@ -704,11 +739,7 @@ export async function startGenerationFn( conversation, messageText, ); - let basePrompt = await injectRagContext( - conversation?.projectId, - messageText, - rawPrompt, - ); + let basePrompt = rawPrompt; // In voice/audio mode the pro audio feature augments the prompt for spoken // output. No-op (returns undefined) in free builds. @@ -733,32 +764,41 @@ export async function startGenerationFn( deps.activeModel, { isRemote }, ); - const messagesForContext = buildMessagesForContext( - targetConversationId, - messageText, - systemPrompt, - ); try { - turnStopped = await generateWithCompactionRetry( - { - id: targetConversationId, - prompt: systemPrompt, - messages: messagesForContext, - setDebugInfo, - assistantEnabled, + // The stable part of the documents follows the system prompt; this turn's passages sit beside + // the question. + turnStopped = await generateWithTurnDocuments({ + conversationId: targetConversationId, + projectId: conversation?.projectId, + question: messageText, + systemPrompt, + isRemote, + generate: documents => { + const promptWithDocuments = documents?.stable + ? `${systemPrompt}\n\n${documents.stable}` + : systemPrompt; + return generateWithCompactionRetry( + { + id: targetConversationId, + prompt: promptWithDocuments, + messages: buildMessagesForContext( + targetConversationId, + documents?.turn ? `${documents.turn}\n\n${messageText}` : messageText, + promptWithDocuments, + ), + setDebugInfo, + assistantEnabled, + }, + activeTools, + conversation?.projectId, + ); }, - activeTools, - conversation?.projectId, - ); + }); } catch (error: any) { const msg = error?.message || error?.toString?.() || 'Failed to generate response'; logger.error('[ChatGen] Generation failed:', msg, error); - const isContextOverflow = - contextCompactionService.isContextFullError(error) || - msg.includes('too long') || - msg.includes('Exceeding the maximum number of tokens') || - msg.includes('Input token ids'); + const isContextOverflow = isContextOverflowError(error); if (isContextOverflow) { deps.setAlertState({ ...showAlert( @@ -915,7 +955,9 @@ async function dispatchResolvedTurn( return { handled: true }; } } - let messageText = appendAttachmentText(opts.text, opts.attachments); + // Documents are not pasted into the question: the document index gives the model what fits + // (services/documents/chatTurn), in this turn and every later one. + let messageText = opts.text; if (shouldGenerateImage && !deps.activeImageModel) messageText = `[User wanted an image but no image model is loaded] ${messageText}`; return { handled: false, messageText }; @@ -940,7 +982,7 @@ export async function dispatchGenerationFn( startTextGeneration: (convId: string, messageText: string, assistantEnabled?: boolean) => Promise, ): Promise { const { text, attachments, conversationId, imageMode = 'auto', assistantEnabled } = call; - const messageTextForRoute = appendAttachmentText(text, attachments); + const messageTextForRoute = text; // [ROUTE-SM]: confirms the turn reached the router (esp. the voice path) + the // final routed destination — so a "pipeline never triggered" is visible in logs. logger.log( @@ -1014,7 +1056,7 @@ export async function handleSendFn( generationService.getState().isGenerating || imageGenerationService.getState().isGenerating ) { - const messageText = appendAttachmentText(text, attachments); + const messageText = text; // Carry the user's forced modality through the queue so a queued force-image send is dispatched as // image on drain — not re-decided at 'auto' by resolveTurnKind (#510). generationService.enqueueMessage({ @@ -1113,10 +1155,7 @@ export async function regenerateResponseFn( // the existing thinking loader covers the full wait instead of appearing only when generation starts. generationSession.begin(targetConversationId); await modelResidencyManager.reclaimSttForGeneration(); // free idle Whisper before the LLM reload (memory-tight) - const messageTextForRoute = appendAttachmentText( - userMessage.content, - userMessage.attachments, - ); + const messageTextForRoute = userMessage.content; // Same decision seam as dispatch (resolveTurnKind): a replay passes the RECORDED kind, which wins // verbatim — an image turn re-runs the image pipeline, NEVER re-classifies to text and fails to // load a text model (the 1★ resend bug). Only a legacy turn with no recorded kind classifies. @@ -1181,45 +1220,58 @@ export async function regenerateResponseFn( ); const isRemote = !!useRemoteServerStore.getState().activeRemoteTextModelId; const activeTools = enabledTools; - const basePrompt = await injectRagContext( - conversation?.projectId, - messageText, - rawPrompt, - ); const useTextHint = !isRemote && !localToolSupport && activeTools.length > 0; // MCP/extension hints come solely from augmentSystemPromptForTools in the tool loop // (see the send path above) — adding them here too would double-inject. const systemPrompt = applyGemma4ThinkToken( useTextHint - ? `${basePrompt}${buildToolSystemPromptHint(activeTools)}` - : basePrompt, + ? `${rawPrompt}${buildToolSystemPromptHint(activeTools)}` + : rawPrompt, deps.activeModel, { isRemote }, ); - const { prefix, filtered } = applyCompactionPrefix( - conversation, - systemPrompt, - messagesUpToUser, - ); try { - await generateWithCompactionRetry( - { - id: targetConversationId, - prompt: systemPrompt, - messages: [...prefix, ...filtered], - setDebugInfo, - assistantEnabled, + // Same documents as send: the project's knowledge base and the chat's attachments, rebuilt for + // this question, with the same retry and the same [S#] links on the reply. + await generateWithTurnDocuments({ + conversationId: targetConversationId, + projectId: conversation?.projectId, + question: messageText, + systemPrompt, + isRemote, + generate: documents => { + const promptWithDocuments = documents?.stable + ? `${systemPrompt}\n\n${documents.stable}` + : systemPrompt; + const turn = documents?.turn; + const replay = turn + ? messagesUpToUser.map(m => + m.id === userMessage.id + ? { ...m, content: `${turn}\n\n${messageText}` } + : m, + ) + : messagesUpToUser; + const { prefix, filtered } = applyCompactionPrefix( + conversation, + promptWithDocuments, + replay, + ); + return generateWithCompactionRetry( + { + id: targetConversationId, + prompt: promptWithDocuments, + messages: [...prefix, ...filtered], + setDebugInfo, + assistantEnabled, + }, + activeTools, + conversation?.projectId, + ); }, - activeTools, - conversation?.projectId, - ); + }); } catch (error: any) { const msg = error?.message || 'Failed to generate response'; - const isContextOverflow = - contextCompactionService.isContextFullError(error) || - msg.includes('too long') || - msg.includes('Exceeding the maximum number of tokens') || - msg.includes('Input token ids'); + const isContextOverflow = isContextOverflowError(error); if (isContextOverflow) { deps.setAlertState({ ...showAlert( diff --git a/src/screens/ChatScreen/useChatScreen.ts b/src/screens/ChatScreen/useChatScreen.ts index 585d17446..8b8003e20 100644 --- a/src/screens/ChatScreen/useChatScreen.ts +++ b/src/screens/ChatScreen/useChatScreen.ts @@ -33,6 +33,7 @@ import { } from './useChatModelActions'; import type { GenerationDeps } from './useChatGenerationActions'; import { getDisplayMessages } from './types'; +import { useDocumentTurnStatusStore } from '../../services/documents/progressStore'; import { needsVisionRepair } from '../../utils/visionRepair'; import { isSuspiciousRecoveredImageModel, @@ -303,6 +304,9 @@ export const useChatScreen = () => { generatingConversationId === activeConversationId; // Replies generating on paired devices. Empty unless Pro's chat-stream service is running. const remotePreviews = useRemoteChatStreamPreviews(activeConversationId); + const documentStatus = useDocumentTurnStatusStore(s => + activeConversationId ? s.byConversation[activeConversationId] : undefined, + ); const localDeviceId = useSyncIdentityStore(s => s.localDeviceId); const displayMessages = useMemo( () => getDisplayMessages(activeConversation?.messages || [], { @@ -314,12 +318,13 @@ export const useChatScreen = () => { isModelLoading, loadingModelName: loadingModel?.name, isGeneratingForThisConversation, + documentStatus, remotePreviews, localDeviceId, }), [activeConversation?.messages, isThinking, hasStreamingText, isStreamingForThisConversation, isModelLoading, loadingModel?.name, - isGeneratingForThisConversation, remotePreviews, localDeviceId], + isGeneratingForThisConversation, documentStatus, remotePreviews, localDeviceId], ); const animateLastN = useChatPresentationLifecycle( diff --git a/src/screens/DocumentPreviewScreen.tsx b/src/screens/DocumentPreviewScreen.tsx index 55bc61899..ac902febd 100644 --- a/src/screens/DocumentPreviewScreen.tsx +++ b/src/screens/DocumentPreviewScreen.tsx @@ -1,10 +1,11 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useRef } from 'react'; import { View, Text, - ScrollView, + FlatList, TouchableOpacity, } from 'react-native'; +import type { DocumentPage } from '@offgrid/rag/documents'; import { LoadingDots } from '../components/LoadingDots'; import { SafeAreaView } from 'react-native-safe-area-context'; import { useNavigation, useRoute, RouteProp } from '@react-navigation/native'; @@ -14,7 +15,7 @@ import RNFS from 'react-native-fs'; import { useTheme, useThemedStyles } from '../theme'; import type { ThemeColors, ThemeShadows } from '../theme'; import { TYPOGRAPHY, SPACING } from '../constants'; -import { documentService } from '../services'; +import { documentStore } from '../services/documents/store'; import { RootStackParamList } from '../navigation/types'; import logger from '../utils/logger'; @@ -38,6 +39,24 @@ const decodeFilePath = (path: string): string => { } }; +/** How long the screen waits for a document that is still being read before showing what is ready. */ +const READ_WAIT_MS = 10_000; +const READ_POLL_MS = 500; + +/** A document's stored pages, waiting briefly while it is still being read. Null when it could not be read. */ +async function waitForPages(documentId: string): Promise { + const deadline = Date.now() + READ_WAIT_MS; + for (;;) { + const stored = await documentStore.getDocument(documentId); + if (stored?.state === 'failed') return null; + const settled = stored && stored.state !== 'reading'; + if (settled || Date.now() >= deadline) { + return (await documentStore.readPages(documentId)).filter((p) => p.text.trim()); + } + await new Promise((resolve) => setTimeout(resolve, READ_POLL_MS)); + } +} + const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ container: { flex: 1, @@ -70,10 +89,6 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ color: colors.textMuted, marginTop: 2, }, - content: { - flex: 1, - padding: SPACING.lg, - }, contentText: { ...TYPOGRAPHY.body, color: colors.text, @@ -93,6 +108,20 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ textAlign: 'center' as const, marginTop: SPACING.md, }, + pageLabel: { + ...TYPOGRAPHY.labelSmall, + color: colors.textMuted, + marginBottom: SPACING.xs, + }, + page: { + paddingHorizontal: SPACING.lg, + paddingVertical: SPACING.md, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + pageCited: { + backgroundColor: colors.surface, + }, emptyText: { ...TYPOGRAPHY.body, color: colors.textMuted, @@ -103,11 +132,13 @@ const createStyles = (colors: ThemeColors, shadows: ThemeShadows) => ({ export const DocumentPreviewScreen: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); - const { filePath, fileName, fileSize } = route.params; + const { filePath, fileName, fileSize, documentId, page } = route.params; const { colors } = useTheme(); const styles = useThemedStyles(createStyles); - const [content, setContent] = useState(null); + const [pages, setPages] = useState(null); + const pageListRef = useRef>(null); + const jumpedToPage = useRef(false); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -116,6 +147,19 @@ export const DocumentPreviewScreen: React.FC = () => { setIsLoading(true); setError(null); + const showPages = async (id: string): Promise => { + const stored = await waitForPages(id); + if (!stored) setError('Could not read this document.'); + else if (stored.length) setPages(stored); + else setError('This document is still being read. Try again in a moment.'); + }; + + if (documentId) { + // A document the index knows: its pages are stored (or being read) by the document engine. + await showPages(documentId); + return; + } + logger.log('[DocumentPreview] ===== Loading document ====='); logger.log('[DocumentPreview] Original filePath param:', filePath); logger.log('[DocumentPreview] fileName:', fileName); @@ -165,16 +209,16 @@ export const DocumentPreviewScreen: React.FC = () => { } logger.log('[DocumentPreview] Found file at:', foundPath); - const attachment = await documentService.processDocumentFromPath(foundPath, fileName); - logger.log('[DocumentPreview] Attachment result:', attachment ? 'success' : 'null'); - - if (attachment?.textContent) { - logger.log('[DocumentPreview] Text content length:', attachment.textContent.length); - setContent(attachment.textContent); - } else { - logger.log('[DocumentPreview] No text content in attachment'); - setError('Could not extract text from this document'); - } + // A file the index has not seen: read it with the document engine, like any attached document. + const id = await RNFS.hash(foundPath, 'sha256'); + const { startDocumentIngest } = await import('../services/documents'); + startDocumentIngest({ + id, + name: fileName, + kind: fileName.toLowerCase().endsWith('.pdf') ? 'pdf' : 'text', + path: foundPath, + }); + await showPages(id); } catch (err: any) { logger.error('[DocumentPreview] ===== Error loading document ====='); logger.error('[DocumentPreview] Error message:', err?.message); @@ -183,7 +227,7 @@ export const DocumentPreviewScreen: React.FC = () => { } finally { setIsLoading(false); } - }, [filePath, fileName]); + }, [filePath, fileName, documentId]); useEffect(() => { loadContent(); @@ -220,10 +264,30 @@ export const DocumentPreviewScreen: React.FC = () => { {error} - ) : content ? ( - - {content} - + ) : pages ? ( + String(item.page)} + initialNumToRender={4} + windowSize={5} + onLayout={() => { + if (jumpedToPage.current) return; + jumpedToPage.current = true; + const index = page ? pages.findIndex((p) => p.page >= page) : -1; + if (index > 0) pageListRef.current?.scrollToIndex({ index, animated: false }); + }} + onScrollToIndexFailed={({ index, averageItemLength }) => { + pageListRef.current?.scrollToOffset({ offset: index * averageItemLength, animated: false }); + setTimeout(() => pageListRef.current?.scrollToIndex({ index, animated: false }), 100); + }} + renderItem={({ item }) => ( + + {item.label ?? `Page ${item.page}`} + {item.text} + + )} + /> ) : ( diff --git a/src/screens/KnowledgeBaseScreen.styles.ts b/src/screens/KnowledgeBaseScreen.styles.ts index 4a9938867..5acad8af7 100644 --- a/src/screens/KnowledgeBaseScreen.styles.ts +++ b/src/screens/KnowledgeBaseScreen.styles.ts @@ -30,21 +30,6 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ addButton: { padding: SPACING.sm, }, - indexingBanner: { - flexDirection: 'row' as const, - alignItems: 'center' as const, - paddingHorizontal: SPACING.lg, - paddingVertical: SPACING.sm, - gap: SPACING.sm, - backgroundColor: colors.surface, - borderBottomWidth: 1, - borderBottomColor: colors.border, - }, - indexingText: { - ...TYPOGRAPHY.bodySmall, - color: colors.textSecondary, - flex: 1, - }, errorCard: { flexDirection: 'row' as const, alignItems: 'center' as const, @@ -125,17 +110,11 @@ export const createStyles = (colors: ThemeColors, _shadows: ThemeShadows) => ({ flex: 1, marginRight: SPACING.sm, }, - docName: { - ...TYPOGRAPHY.body, - color: colors.text, - }, - docSize: { - ...TYPOGRAPHY.labelSmall, - color: colors.textMuted, - marginTop: 2, - }, docDelete: { padding: SPACING.sm, marginLeft: SPACING.sm, }, + transferIcon: { + marginRight: SPACING.sm, + }, }); \ No newline at end of file diff --git a/src/screens/KnowledgeBaseScreen.tsx b/src/screens/KnowledgeBaseScreen.tsx index 5fadc75fa..b46ed6137 100644 --- a/src/screens/KnowledgeBaseScreen.tsx +++ b/src/screens/KnowledgeBaseScreen.tsx @@ -19,20 +19,26 @@ import logger from '../utils/logger'; import { useTheme, useThemedStyles } from '../theme'; import { createStyles } from './KnowledgeBaseScreen.styles'; import { useProjectStore } from '../stores'; -import { ragService } from '../services/rag'; -import type { RagDocument } from '../services/rag'; +import { knowledgeBase } from '../services/rag'; +import type { KnowledgeDocument } from '../services/rag'; import { RootStackParamList } from '../navigation/types'; +import { + KnowledgeDocumentInfo, + KnowledgeTransferInfo, +} from '../components/knowledge/KnowledgeDocumentInfo'; +import { useKnowledgeDocumentTransfers } from '../hooks/useKnowledgeDocumentTransfers'; +import { + arrivingKnowledgeDocumentTransfers, + knowledgeDocumentTransferKey, + rowKnowledgeDocumentTransfer, + type KnowledgeDocumentTransfer, +} from '../services/sync/knowledgeDocumentTransfer'; +import { openDocumentPage } from '../components/ChatDocumentsSheet'; import { isPickerStuck } from '../utils/pickerErrorUtils'; type NavigationProp = NativeStackNavigationProp; type RouteProps = RouteProp; - -const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; - export const KnowledgeBaseScreen: React.FC = () => { const navigation = useNavigation(); const route = useRoute(); @@ -40,8 +46,8 @@ export const KnowledgeBaseScreen: React.FC = () => { const { colors } = useTheme(); const styles = useThemedStyles(createStyles); - const [kbDocs, setKbDocs] = useState([]); - const [indexingFile, setIndexingFile] = useState(null); + const [kbDocs, setKbDocs] = useState([]); + const [addingFile, setAddingFile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isPicking, setIsPicking] = useState(false); const [indexError, setIndexError] = useState<{ fileName: string; message: string } | null>(null); @@ -52,7 +58,7 @@ export const KnowledgeBaseScreen: React.FC = () => { const loadKbDocs = useCallback(async () => { try { setIsLoading(true); - setKbDocs(await ragService.getDocumentsByProject(projectId)); + setKbDocs(await knowledgeBase.getDocumentsByProject(projectId)); } catch (err: any) { Alert.alert('Error', err?.message || 'Failed to load documents'); } finally { @@ -64,6 +70,19 @@ export const KnowledgeBaseScreen: React.FC = () => { loadKbDocs(); }, [loadKbDocs]); + // Files moving to or from paired devices. A received file reloads the list so its own row shows. + const reloadQuietly = useCallback(() => { + knowledgeBase + .getDocumentsByProject(projectId) + .then(setKbDocs) + .catch(() => undefined); + }, [projectId]); + const { transfers, dismiss: dismissTransfer } = useKnowledgeDocumentTransfers(projectId, reloadQuietly); + const arriving = arrivingKnowledgeDocumentTransfers( + transfers, + new Set(kbDocs.map((doc) => doc.sync_id)), + ); + const handleAddDocument = async () => { if (isPickingRef.current) { logger.log('[KnowledgeBase] blocked — picker already in flight'); @@ -88,19 +107,19 @@ export const KnowledgeBaseScreen: React.FC = () => { for (let i = 0; i < files.length; i++) { const file = files[i]; const fileName = file.name || 'document'; - setIndexingFile(files.length > 1 ? `${fileName} (${i + 1}/${files.length})` : fileName); + setAddingFile(files.length > 1 ? `${fileName} (${i + 1}/${files.length})` : fileName); const pathForDb = await resolvePickedFileUri(file.uri, fileName); - logger.log(`[KnowledgeBase] indexing file ${i + 1}/${files.length} — name: ${fileName}, path: ${pathForDb?.substring(0, 80)}`); + logger.log(`[KnowledgeBase] adding file ${i + 1}/${files.length} — name: ${fileName}, path: ${pathForDb?.substring(0, 80)}`); try { - await ragService.indexDocument({ projectId, filePath: pathForDb, fileName, fileSize: file.size || 0 }); - logger.log(`[KnowledgeBase] indexed successfully: ${fileName}`); + // Adding keeps the file and starts reading it in the background; its row shows the progress. + await knowledgeBase.addFile({ projectId, filePath: pathForDb, fileName, fileSize: file.size || 0 }); + logger.log(`[KnowledgeBase] added: ${fileName}`); } catch (indexErr: any) { - // The index aborted mid-way and rolled back (ragService.indexDocument is atomic — no - // half-indexed doc is left behind). Surface it as a persistent, retriable error card on - // the screen rather than a fire-and-forget alert, so the user sees the failure and can retry. - logger.error(`[KnowledgeBase] index failed for "${fileName}" — ${indexErr?.message}`); + // The file was not added (unsupported, unreadable, or already here). Surface it as a + // persistent, retriable error card rather than a fire-and-forget alert. + logger.error(`[KnowledgeBase] add failed for "${fileName}" — ${indexErr?.message}`); setIndexError({ fileName, message: indexErr?.message || 'Unknown error' }); } } @@ -120,21 +139,21 @@ export const KnowledgeBaseScreen: React.FC = () => { } finally { isPickingRef.current = false; setIsPicking(false); - setIndexingFile(null); + setAddingFile(null); logger.log('[KnowledgeBase] picker settled, lock released'); } }; const handleToggleDocument = async (docId: number, enabled: boolean) => { try { - await ragService.toggleDocument(docId, enabled); + await knowledgeBase.toggleDocument(docId, enabled); await loadKbDocs(); } catch (err: any) { Alert.alert('Error', err?.message || 'Failed to update document'); } }; - const handleDeleteDocument = (doc: RagDocument) => { + const handleDeleteDocument = (doc: KnowledgeDocument) => { Alert.alert( 'Remove Document', `Remove "${doc.name}" from the knowledge base?`, @@ -145,7 +164,7 @@ export const KnowledgeBaseScreen: React.FC = () => { style: 'destructive', onPress: async () => { try { - await ragService.deleteDocument(doc.id); + await knowledgeBase.deleteDocument(doc.id); await loadKbDocs(); } catch (err: any) { Alert.alert('Error', err?.message || 'Failed to remove document'); @@ -164,18 +183,25 @@ export const KnowledgeBaseScreen: React.FC = () => { // whether a document was in use, and could not toggle it: the whole row read as one button that // opens a preview. Opening, toggling and deleting are three different actions and each needs to // be reachable on its own. - const renderDoc = ({ item }: { item: RagDocument }) => ( + const renderDoc = ({ item }: { item: KnowledgeDocument }) => ( navigation.navigate('DocumentPreview', { filePath: item.path, fileName: item.name, fileSize: item.size })} + onPress={() => { + if (item.document_id) openDocumentPage(navigation, { documentId: item.document_id, name: item.name }); + }} + disabled={!item.document_id} activeOpacity={0.7} accessibilityRole="button" accessibilityLabel={`Knowledge document ${item.name}`} testID={`knowledge-document-row-${item.sync_id}`} > - {item.name} - {formatFileSize(item.size)} + { ); + const renderArriving = (transfer: KnowledgeDocumentTransfer) => ( + + + + + + {transfer.status === 'failed' && ( + dismissTransfer(transfer)} + accessibilityRole="button" + accessibilityLabel={`Dismiss ${transfer.fileName}`} + > + + + )} + + ); + return ( @@ -213,8 +267,14 @@ export const KnowledgeBaseScreen: React.FC = () => { {project?.name || 'Knowledge Base'} - - {indexingFile ? ( + + {addingFile ? ( ) : ( @@ -222,14 +282,7 @@ export const KnowledgeBaseScreen: React.FC = () => { - {indexingFile && ( - - - Indexing {indexingFile}... - - )} - - {indexError && !indexingFile && ( + {indexError && !addingFile && ( @@ -253,7 +306,7 @@ export const KnowledgeBaseScreen: React.FC = () => { - ) : kbDocs.length === 0 ? ( + ) : kbDocs.length === 0 && arriving.length === 0 ? ( No documents yet @@ -267,6 +320,7 @@ export const KnowledgeBaseScreen: React.FC = () => { data={kbDocs} renderItem={renderDoc} keyExtractor={(item) => String(item.id)} + ListHeaderComponent={arriving.length > 0 ? <>{arriving.map(renderArriving)} : null} style={styles.list} contentContainerStyle={styles.listContent} showsVerticalScrollIndicator={false} diff --git a/src/screens/ProjectDetailKnowledgeBaseSection.tsx b/src/screens/ProjectDetailKnowledgeBaseSection.tsx index 64e361f5b..14ba4077c 100644 --- a/src/screens/ProjectDetailKnowledgeBaseSection.tsx +++ b/src/screens/ProjectDetailKnowledgeBaseSection.tsx @@ -19,24 +19,28 @@ import { resolvePickedFileUri } from '../utils/resolvePickedFileUri'; import { Button } from '../components/Button'; import { showAlert, AlertState } from '../components/CustomAlert'; import { PasteNoteSheet } from '../components/knowledge/PasteNoteSheet'; -import { ragService } from '../services/rag'; -import type { RagDocument } from '../services/rag'; +import { + KnowledgeDocumentInfo, + KnowledgeTransferInfo, +} from '../components/knowledge/KnowledgeDocumentInfo'; +import { useKnowledgeDocumentTransfers } from '../hooks/useKnowledgeDocumentTransfers'; +import { + arrivingKnowledgeDocumentTransfers, + knowledgeDocumentTransferKey, + rowKnowledgeDocumentTransfer, +} from '../services/sync/knowledgeDocumentTransfer'; +import { knowledgeBase } from '../services/rag'; +import type { KnowledgeDocument } from '../services/rag'; import { isPickerStuck } from '../utils/pickerErrorUtils'; -const formatFileSize = (bytes: number): string => { - if (bytes < 1024) return `${bytes} B`; - return bytes < 1024 * 1024 - ? `${(bytes / 1024).toFixed(1)} KB` - : `${(bytes / (1024 * 1024)).toFixed(1)} MB`; -}; - export interface KBSectionProps { projectId: string; colors: any; styles: any; setAlertState: (state: AlertState) => void; onNavigateToKb: () => void; - onDocumentPress: (doc: RagDocument) => void; + /** Opens the file's pages; only called for a file the document index knows. */ + onDocumentPress: (doc: KnowledgeDocument & { document_id: string }) => void; } export const KnowledgeBaseSection: React.FC = ({ @@ -47,15 +51,15 @@ export const KnowledgeBaseSection: React.FC = ({ onNavigateToKb, onDocumentPress, }) => { - const [kbDocs, setKbDocs] = useState([]); - const [indexingFile, setIndexingFile] = useState(null); + const [kbDocs, setKbDocs] = useState([]); + const [addingFile, setAddingFile] = useState(null); const [isPicking, setIsPicking] = useState(false); const [pasting, setPasting] = useState(false); const isPickingRef = useRef(false); const loadKbDocs = useCallback(async () => { try { - setKbDocs(await ragService.getDocumentsByProject(projectId)); + setKbDocs(await knowledgeBase.getDocumentsByProject(projectId)); } catch (err: any) { setAlertState( showAlert('Error', err?.message || 'Failed to load documents'), @@ -67,6 +71,16 @@ export const KnowledgeBaseSection: React.FC = ({ loadKbDocs(); }, [loadKbDocs]); + // Files moving to or from paired devices. A received file reloads the list so its own row shows. + const { transfers, dismiss: dismissTransfer } = useKnowledgeDocumentTransfers( + projectId, + loadKbDocs, + ); + const arriving = arrivingKnowledgeDocumentTransfers( + transfers, + new Set(kbDocs.map(doc => doc.sync_id)), + ); + const handleAddDocument = async () => { if (isPickingRef.current) return; isPickingRef.current = true; @@ -83,7 +97,7 @@ export const KnowledgeBaseSection: React.FC = ({ for (let i = 0; i < files.length; i++) { const file = files[i]; const fileName = file.name || 'document'; - setIndexingFile( + setAddingFile( files.length > 1 ? `${fileName} (${i + 1}/${files.length})` : fileName, @@ -91,7 +105,8 @@ export const KnowledgeBaseSection: React.FC = ({ const pathForDb = await resolvePickedFileUri(file.uri, fileName); - await ragService.indexDocument({ + // Adding keeps the file and starts reading it in the background; its row shows the progress. + await knowledgeBase.addFile({ projectId, filePath: pathForDb, fileName, @@ -112,12 +127,12 @@ export const KnowledgeBaseSection: React.FC = ({ return; } setAlertState( - showAlert('Error', err.message || 'Failed to index document'), + showAlert('Error', err.message || 'Failed to add document'), ); } finally { isPickingRef.current = false; setIsPicking(false); - setIndexingFile(null); + setAddingFile(null); } }; @@ -125,18 +140,18 @@ export const KnowledgeBaseSection: React.FC = ({ title: string, text: string, ): Promise => { - setIndexingFile(title.trim() || 'pasted text'); + setAddingFile(title.trim() || 'pasted text'); try { - await ragService.indexPastedText({ projectId, title, text }); + await knowledgeBase.addPastedText({ projectId, title, text }); await loadKbDocs(); } finally { - setIndexingFile(null); + setAddingFile(null); } }; const handleToggleDocument = async (docId: number, enabled: boolean) => { try { - await ragService.toggleDocument(docId, enabled); + await knowledgeBase.toggleDocument(docId, enabled); await loadKbDocs(); } catch (err: any) { setAlertState( @@ -145,7 +160,7 @@ export const KnowledgeBaseSection: React.FC = ({ } }; - const handleDeleteDocument = (doc: RagDocument) => { + const handleDeleteDocument = (doc: KnowledgeDocument) => { setAlertState( showAlert( 'Remove Document', @@ -156,7 +171,7 @@ export const KnowledgeBaseSection: React.FC = ({ text: 'Remove', style: 'destructive', onPress: () => { - ragService + knowledgeBase .deleteDocument(doc.id) .then(() => loadKbDocs()) .catch((err: any) => @@ -203,7 +218,7 @@ export const KnowledgeBaseSection: React.FC = ({ size="small" onPress={() => setPasting(true)} testID="kb-paste-text" - disabled={isPicking || !!indexingFile} + disabled={isPicking || !!addingFile} icon={} />