mirror of
http://88.130.71.182:3000/BlitTech/contexta_mb.git
synced 2026-06-12 23:23:22 +00:00
287 lines
10 KiB
TypeScript
287 lines
10 KiB
TypeScript
import React, { useState } from 'react';
|
|
import {
|
|
View,
|
|
Text,
|
|
StyleSheet,
|
|
FlatList,
|
|
TouchableOpacity,
|
|
Alert,
|
|
} from 'react-native';
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { COLORS, FONT_SIZE, SPACING, RADIUS } from '../../../theme';
|
|
import { useTheme } from '../../../theme';
|
|
import { Card, StatusBadge, Spinner, EmptyState, Button, Input } from '../../../components/ui';
|
|
import { useToast } from '../../../contexts/ToastContext';
|
|
import { documentsAPI, urlSourcesAPI } from '../../../services/api';
|
|
import { Document, URLSource } from '../../../types';
|
|
|
|
interface Props {
|
|
chatbotId: string;
|
|
}
|
|
|
|
export function DocumentsTab({ chatbotId }: Props) {
|
|
const { theme } = useTheme();
|
|
const toast = useToast();
|
|
const qc = useQueryClient();
|
|
const [urlInput, setUrlInput] = useState('');
|
|
const [addingUrl, setAddingUrl] = useState(false);
|
|
const [showUrlInput, setShowUrlInput] = useState(false);
|
|
|
|
const { data: documents, isLoading: loadingDocs } = useQuery({
|
|
queryKey: ['documents', chatbotId],
|
|
queryFn: () => documentsAPI.list(chatbotId),
|
|
});
|
|
|
|
const { data: urlSources } = useQuery({
|
|
queryKey: ['urlSources', chatbotId],
|
|
queryFn: () => urlSourcesAPI.list(chatbotId),
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (docId: string) => documentsAPI.delete(chatbotId, docId),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['documents', chatbotId] });
|
|
toast.success('Document deleted');
|
|
},
|
|
onError: () => toast.error('Failed to delete document'),
|
|
});
|
|
|
|
const retryMutation = useMutation({
|
|
mutationFn: (docId: string) => documentsAPI.retry(chatbotId, docId),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['documents', chatbotId] });
|
|
toast.success('Retry started');
|
|
},
|
|
});
|
|
|
|
const deleteUrlMutation = useMutation({
|
|
mutationFn: (sourceId: string) => urlSourcesAPI.delete(chatbotId, sourceId),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['urlSources', chatbotId] });
|
|
toast.success('URL source removed');
|
|
},
|
|
});
|
|
|
|
const handleAddUrl = async () => {
|
|
const url = urlInput.trim();
|
|
if (!url) return;
|
|
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
|
toast.error('Enter a valid URL starting with http:// or https://');
|
|
return;
|
|
}
|
|
setAddingUrl(true);
|
|
try {
|
|
await urlSourcesAPI.add(chatbotId, url);
|
|
qc.invalidateQueries({ queryKey: ['urlSources', chatbotId] });
|
|
setUrlInput('');
|
|
setShowUrlInput(false);
|
|
toast.success('URL added! Scraping in background...');
|
|
} catch (err: any) {
|
|
toast.error(err?.response?.data?.detail ?? 'Failed to add URL');
|
|
} finally {
|
|
setAddingUrl(false);
|
|
}
|
|
};
|
|
|
|
const confirmDelete = (doc: Document) => {
|
|
Alert.alert('Delete Document', `Remove "${doc.file_name}"?`, [
|
|
{ text: 'Cancel', style: 'cancel' },
|
|
{ text: 'Delete', style: 'destructive', onPress: () => deleteMutation.mutate(doc.id) },
|
|
]);
|
|
};
|
|
|
|
const formatSize = (bytes: number) => {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
};
|
|
|
|
type ListItem =
|
|
| { type: 'doc'; data: Document }
|
|
| { type: 'url'; data: URLSource }
|
|
| { type: 'upload-tip' };
|
|
|
|
const listData: ListItem[] = [
|
|
{ type: 'upload-tip' },
|
|
...(documents ?? []).map((d: Document) => ({ type: 'doc' as const, data: d })),
|
|
...(urlSources ?? []).map((u: URLSource) => ({ type: 'url' as const, data: u })),
|
|
];
|
|
|
|
return (
|
|
<View style={[styles.container, { backgroundColor: theme.bg }]}>
|
|
{/* Add URL bar */}
|
|
<View style={[styles.toolbar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
|
|
<Button
|
|
title="+ Add URL"
|
|
size="sm"
|
|
onPress={() => setShowUrlInput(v => !v)}
|
|
/>
|
|
<Text style={[styles.toolbarHint, { color: theme.textMuted }]}>
|
|
{(documents ?? []).length + (urlSources ?? []).length} sources
|
|
</Text>
|
|
</View>
|
|
|
|
{showUrlInput && (
|
|
<View style={[styles.urlBar, { backgroundColor: theme.surface, borderBottomColor: theme.border }]}>
|
|
<Input
|
|
value={urlInput}
|
|
onChangeText={setUrlInput}
|
|
placeholder="https://example.com/page"
|
|
keyboardType="url"
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
style={styles.urlInput}
|
|
/>
|
|
<View style={styles.urlActions}>
|
|
<Button title="Cancel" variant="ghost" size="sm" onPress={() => { setShowUrlInput(false); setUrlInput(''); }} />
|
|
<Button title="Scrape" size="sm" onPress={handleAddUrl} loading={addingUrl} />
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
<FlatList
|
|
data={listData}
|
|
keyExtractor={(item, i) => (item.type === 'upload-tip' ? 'tip' : item.type === 'doc' ? item.data.id : item.data.id)}
|
|
contentContainerStyle={styles.list}
|
|
renderItem={({ item }) => {
|
|
if (item.type === 'upload-tip') {
|
|
return (
|
|
<Card style={[styles.tipCard, { borderColor: COLORS.primaryUltraLight }] as any}>
|
|
<Text style={styles.tipIcon}>💡</Text>
|
|
<View style={styles.tipBody}>
|
|
<Text style={[styles.tipTitle, { color: theme.text }]}>Upload files from the web app</Text>
|
|
<Text style={[styles.tipDesc, { color: theme.textSecondary }]}>
|
|
PDF, DOCX, CSV, XLSX, TXT and MD files can be uploaded from the Contexta web dashboard. Use "Add URL" here to scrape web pages directly.
|
|
</Text>
|
|
</View>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
if (item.type === 'doc') {
|
|
const doc = item.data as Document;
|
|
const icon = doc.file_type?.includes('pdf') ? '📄'
|
|
: doc.file_type?.includes('csv') || doc.file_type?.includes('xlsx') ? '📊'
|
|
: '📝';
|
|
return (
|
|
<View style={[styles.docItem, { backgroundColor: theme.surface, borderColor: theme.border }]}>
|
|
<View style={[styles.docIcon, { backgroundColor: COLORS.primaryUltraLight }]}>
|
|
<Text style={styles.docIconText}>{icon}</Text>
|
|
</View>
|
|
<View style={styles.docInfo}>
|
|
<Text style={[styles.docName, { color: theme.text }]} numberOfLines={1}>{doc.file_name}</Text>
|
|
<View style={styles.docMeta}>
|
|
<Text style={[styles.docSize, { color: theme.textMuted }]}>{formatSize(doc.file_size)}</Text>
|
|
{doc.chunk_count > 0 && (
|
|
<Text style={[styles.docSize, { color: theme.textMuted }]}> · {doc.chunk_count} chunks</Text>
|
|
)}
|
|
</View>
|
|
<View style={styles.docFooter}>
|
|
<StatusBadge status={doc.status} />
|
|
{doc.status === 'failed' && (
|
|
<TouchableOpacity onPress={() => retryMutation.mutate(doc.id)}>
|
|
<Text style={[styles.retryText, { color: COLORS.primary }]}>Retry</Text>
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity onPress={() => confirmDelete(doc)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
<Text style={styles.deleteIcon}>✕</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
// url source
|
|
const src = item.data as URLSource;
|
|
return (
|
|
<View style={[styles.docItem, { backgroundColor: theme.surface, borderColor: theme.border }]}>
|
|
<View style={[styles.docIcon, { backgroundColor: '#e0f2fe' }]}>
|
|
<Text style={styles.docIconText}>🌐</Text>
|
|
</View>
|
|
<View style={styles.docInfo}>
|
|
<Text style={[styles.docName, { color: theme.text }]} numberOfLines={1}>
|
|
{src.page_title ?? src.url}
|
|
</Text>
|
|
<Text style={[styles.docSize, { color: theme.textMuted }]} numberOfLines={1}>{src.url}</Text>
|
|
<View style={styles.docFooter}>
|
|
<StatusBadge status={src.status} />
|
|
{src.chunk_count ? (
|
|
<Text style={[styles.docSize, { color: theme.textMuted }]}>{src.chunk_count} chunks</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
<TouchableOpacity onPress={() => deleteUrlMutation.mutate(src.id)} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}>
|
|
<Text style={styles.deleteIcon}>✕</Text>
|
|
</TouchableOpacity>
|
|
</View>
|
|
);
|
|
}}
|
|
ListEmptyComponent={
|
|
loadingDocs ? <Spinner centered /> : null
|
|
}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
container: { flex: 1 },
|
|
|
|
toolbar: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
paddingHorizontal: SPACING.md,
|
|
paddingVertical: SPACING.sm,
|
|
borderBottomWidth: 1,
|
|
},
|
|
toolbarHint: { fontSize: FONT_SIZE.xs },
|
|
|
|
urlBar: {
|
|
paddingHorizontal: SPACING.md,
|
|
paddingBottom: SPACING.sm,
|
|
borderBottomWidth: 1,
|
|
gap: SPACING.xs,
|
|
},
|
|
urlInput: { marginBottom: 0 },
|
|
urlActions: { flexDirection: 'row', justifyContent: 'flex-end', gap: SPACING.sm },
|
|
|
|
list: { padding: SPACING.md, gap: SPACING.sm },
|
|
|
|
tipCard: {
|
|
flexDirection: 'row',
|
|
gap: SPACING.md,
|
|
backgroundColor: '#f0f4ff',
|
|
borderWidth: 1,
|
|
},
|
|
tipIcon: { fontSize: 22 },
|
|
tipBody: { flex: 1, gap: 4 },
|
|
tipTitle: { fontSize: FONT_SIZE.sm, fontWeight: '600' },
|
|
tipDesc: { fontSize: FONT_SIZE.xs, lineHeight: 18 },
|
|
|
|
docItem: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
borderRadius: RADIUS.lg,
|
|
padding: SPACING.md,
|
|
borderWidth: 1,
|
|
gap: SPACING.sm,
|
|
},
|
|
docIcon: {
|
|
width: 40,
|
|
height: 40,
|
|
borderRadius: RADIUS.md,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
docIconText: { fontSize: 20 },
|
|
docInfo: { flex: 1, gap: 3 },
|
|
docName: { fontSize: FONT_SIZE.sm, fontWeight: '500' },
|
|
docMeta: { flexDirection: 'row', alignItems: 'center' },
|
|
docSize: { fontSize: FONT_SIZE.xs },
|
|
docFooter: { flexDirection: 'row', alignItems: 'center', gap: SPACING.sm, marginTop: 2 },
|
|
retryText: { fontSize: FONT_SIZE.xs, fontWeight: '600' },
|
|
deleteIcon: { fontSize: 16, color: '#9ca3af', padding: SPACING.xs },
|
|
});
|