diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e1d411 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Strapi secrets - change these in production! +JWT_SECRET=your-jwt-secret-here +ADMIN_JWT_SECRET=your-admin-jwt-secret-here +APP_KEYS=your-app-key-1,your-app-key-2 +API_TOKEN_SALT=your-api-token-salt +TRANSFER_TOKEN_SALT=your-transfer-token-salt + +# Optional: Strapi API token for backend integration +STRAPI_API_TOKEN= diff --git a/.gitignore b/.gitignore index 0408bd6..9502af8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,48 +1,7 @@ -# Dependencies -node_modules/ -backend/node_modules/ -frontend/node_modules/ -.pnp -.pnp.js - -# Build outputs -frontend/dist/ -frontend/dist-ssr/ -backend/dist/ -*.local - -# Environment variables +node_modules +dist .env -.env.local -.env.*.local - -# Logs -logs/ *.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* -pnpm-debug.log* -lerna-debug.log* - -# Editor directories and files -.vscode/* -!.vscode/extensions.json -.idea/ -*.suo -*.ntvs* -*.njsproj -*.sln -*.sw? - -# OS files -.DS_Store -Thumbs.db - -# Testing -coverage/ - -# Temporary files -*.tmp -*.temp -.cache/ +data/ +strapi/ +!.env.example diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..2fe36cc --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.env +*.log +data diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..d50dbdd --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,20 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install dependencies +COPY package*.json ./ +RUN npm ci --only=production + +# Copy source +COPY src/ ./src/ + +# Create data directories +RUN mkdir -p /app/data/uploads /app/data/chromadb + +EXPOSE 3002 + +ENV NODE_ENV=production +ENV PORT=3002 + +CMD ["node", "src/index.js"] diff --git a/backend/src/index.js b/backend/src/index.js index d76d043..1bf4909 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,7 +6,7 @@ const fs = require('fs'); const path = require('path'); const app = express(); -const PORT = process.env.PORT || 3001; +const PORT = process.env.PORT || 3002; // Middleware app.use(cors()); @@ -31,6 +31,42 @@ const knowledgeBases = new Map(); const knowledgeFiles = new Map(); const uploadedFiles = new Map(); +// Token usage tracking +const tokenUsageStats = new Map(); // conversationId -> aggregated token stats + +// In-memory storage for token usage events +const tokenUsageLog = []; + +// Extract token usage from SSE response +function extractTokenUsage(fullResponse) { + try { + // Parse the last SSE data block which contains usage info + const lines = fullResponse.split('\n'); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i]; + if (line.startsWith('data: ')) { + const data = line.slice(6).trim(); + if (data === '[DONE]') continue; + try { + const parsed = JSON.parse(data); + if (parsed.usage) { + return { + promptTokens: parsed.usage.prompt_tokens || 0, + completionTokens: parsed.usage.completion_tokens || 0, + totalTokens: parsed.usage.total_tokens || 0, + }; + } + } catch { + // Not valid JSON, skip + } + } + } + return null; + } catch { + return null; + } +} + // Global ChromaDB client (recreated per request when path changes) function getChromaClient(persistDir = './data/chromadb') { return new ChromaClient({ @@ -78,13 +114,31 @@ app.post('/api/v1/chat/completions', async (req, res) => { const reader = response.body.getReader(); const decoder = new TextDecoder(); + let fullResponse = ''; while (true) { const { done, value } = await reader.read(); if (done) break; - res.write(decoder.decode(value, { stream: true })); + const chunk = decoder.decode(value, { stream: true }); + fullResponse += chunk; + res.write(chunk); } res.end(); + + // Parse token usage from the final SSE message + const tokenUsage = extractTokenUsage(fullResponse); + if (tokenUsage) { + const usageEntry = { + timestamp: Date.now(), + conversationId: req.body.conversationId || 'unknown', + model, + promptTokens: tokenUsage.promptTokens, + completionTokens: tokenUsage.completionTokens, + totalTokens: tokenUsage.totalTokens, + }; + tokenUsageLog.push(usageEntry); + console.log(`Token usage: ${usageEntry.totalTokens} tokens (${usageEntry.promptTokens} prompt, ${usageEntry.completionTokens} completion) for model ${model}`); + } } catch (error) { console.error('Proxy error:', error); res.status(500).json({ @@ -427,6 +481,60 @@ app.get('/api/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString() }); }); +// Token usage tracking endpoints +app.get('/api/token-usage', (req, res) => { + try { + const { period = 'all', conversationId } = req.query; + + let filtered = [...tokenUsageLog]; + + if (conversationId) { + filtered = filtered.filter((e) => e.conversationId === conversationId); + } + + if (period === 'today') { + const todayStart = new Date(); + todayStart.setHours(0, 0, 0, 0); + filtered = filtered.filter((e) => e.timestamp >= todayStart.getTime()); + } else if (period === 'week') { + const weekStart = new Date(); + weekStart.setDate(weekStart.getDate() - 7); + filtered = filtered.filter((e) => e.timestamp >= weekStart.getTime()); + } + + const promptTokens = filtered.reduce((sum, e) => sum + e.promptTokens, 0); + const completionTokens = filtered.reduce((sum, e) => sum + e.completionTokens, 0); + const totalTokens = filtered.reduce((sum, e) => sum + e.totalTokens, 0); + + // Per-model breakdown + const modelBreakdown = {}; + filtered.forEach((entry) => { + if (!modelBreakdown[entry.model]) { + modelBreakdown[entry.model] = { promptTokens: 0, completionTokens: 0, totalTokens: 0, requestCount: 0 }; + } + modelBreakdown[entry.model].promptTokens += entry.promptTokens; + modelBreakdown[entry.model].completionTokens += entry.completionTokens; + modelBreakdown[entry.model].totalTokens += entry.totalTokens; + modelBreakdown[entry.model].requestCount += 1; + }); + + res.json({ + summary: { promptTokens, completionTokens, totalTokens, requestCount: filtered.length }, + modelBreakdown, + history: filtered.slice(-100), // last 100 entries + }); + } catch (error) { + console.error('Token usage error:', error); + res.status(500).json({ error: error.message }); + } +}); + +app.delete('/api/token-usage', (req, res) => { + tokenUsageLog.length = 0; + tokenUsageStats.clear(); + res.json({ success: true }); +}); + app.listen(PORT, () => { console.log(`AIUI Backend running on port ${PORT}`); console.log(`Upload directory: ${uploadDir}`); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4b0716f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +version: "3.8" + +services: + # Nginx reverse proxy + nginx: + image: nginx:alpine + ports: + - "80:80" + volumes: + - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + - frontend + - backend + - strapi + networks: + - aiui-network + + # Frontend (React + Vite built to static files) + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + expose: + - "80" + networks: + - aiui-network + restart: unless-stopped + + # Backend (Express + ChromaDB) + backend: + build: + context: ./backend + dockerfile: Dockerfile + expose: + - "3002" + volumes: + - ./data/backend:/app/data + environment: + - NODE_ENV=production + - PORT=3002 + - STRAPI_URL=http://strapi:1337 + - STRAPI_API_TOKEN=${STRAPI_API_TOKEN:-} + networks: + - aiui-network + restart: unless-stopped + + # Strapi CMS (SQLite) + strapi: + image: strapi/strapi:5 + expose: + - "1337" + volumes: + - ./data/strapi:/srv/app + environment: + - DATABASE_CLIENT=sqlite + - DATABASE_FILENAME=.tmp/data.db + - JWT_SECRET=${JWT_SECRET:-changemejwt} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET:-changemeadmin} + - APP_KEYS=${APP_KEYS:-changeme1,changeme2} + - API_TOKEN_SALT=${API_TOKEN_SALT:-changemesalt} + - TRANSFER_TOKEN_SALT=${TRANSFER_TOKEN_SALT:-changemetoken} + - NODE_ENV=production + networks: + - aiui-network + restart: unless-stopped + +networks: + aiui-network: + driver: bridge + +volumes: + backend-data: + strapi-data: diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..beed316 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,5 @@ +node_modules +dist +.git +.env +*.log diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..3090b9a --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,27 @@ +# Build stage +FROM node:20-alpine AS builder + +WORKDIR /app + +# Copy package files +COPY package*.json ./ +RUN npm ci + +# Copy source +COPY . . + +# Build +RUN npm run build + +# Production stage - serve with nginx +FROM nginx:alpine + +# Copy built files +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx config +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..54e1f26 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,44 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Gzip compression + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml; + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # API proxy + location /api/ { + proxy_pass http://backend:3002/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # SSE support + proxy_buffering off; + proxy_cache off; + proxy_read_timeout 86400s; + } + + # Strapi admin/proxy (optional) + location /strapi/ { + proxy_pass http://strapi:1337/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/src/components/chat/ChatArea.tsx b/frontend/src/components/chat/ChatArea.tsx index 42fd844..2065cd3 100644 --- a/frontend/src/components/chat/ChatArea.tsx +++ b/frontend/src/components/chat/ChatArea.tsx @@ -105,7 +105,8 @@ export default function ChatArea({ conversationId }: ChatAreaProps) { settings.temperature, settings.maxTokens, systemPrompt, - abortControllerRef.current.signal + abortControllerRef.current.signal, + conversationId ); for await (const chunk of stream) { diff --git a/frontend/src/components/chat/SettingsModal.tsx b/frontend/src/components/chat/SettingsModal.tsx index a89705f..9df5cb4 100644 --- a/frontend/src/components/chat/SettingsModal.tsx +++ b/frontend/src/components/chat/SettingsModal.tsx @@ -1,10 +1,12 @@ import { useState, useEffect } from 'react'; -import { X, RefreshCw, Eye, EyeOff } from 'lucide-react'; +import { X, RefreshCw, Eye, EyeOff, BarChart3 } from 'lucide-react'; import { useChatStore } from '@/stores/chatStore'; import { fetchModels } from '@/services/api'; +import TokenUsagePanel from './TokenUsagePanel'; export default function SettingsModal() { const { settings, updateSettings, toggleSettings, closeSettings } = useChatStore(); + const [activeTab, setActiveTab] = useState<'settings' | 'tokens'>('settings'); const [localSettings, setLocalSettings] = useState(settings); const [models, setModels] = useState([]); const [isLoadingModels, setIsLoadingModels] = useState(false); @@ -52,20 +54,51 @@ export default function SettingsModal() { className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={toggleSettings} /> -
+
{/* Header */} -
-

Settings

- +
+
+

Settings

+ +
+ + {/* Tabs */} +
+ + +
{/* Content */} -
+
+ {activeTab === 'settings' ? ( +
{error && (
{error} @@ -217,59 +250,67 @@ export default function SettingsModal() { />
-
+
- {/* Embedding Model */} -
- - - setLocalSettings((prev) => ({ ...prev, embeddingModel: e.target.value })) - } - placeholder="text-embedding-3-small" - className="input-field" - /> -

- Model used for knowledge base embeddings -

-
+ {/* Embedding Model */} +
+ + + setLocalSettings((prev) => ({ ...prev, embeddingModel: e.target.value })) + } + placeholder="text-embedding-3-small" + className="input-field" + /> +

+ Model used for knowledge base embeddings +

+
- {/* ChromaDB Directory */} -
- - - setLocalSettings((prev) => ({ ...prev, chromaDir: e.target.value })) - } - placeholder="./data/chromadb" - className="input-field" - /> -

- Path to ChromaDB persistent storage on the server -

-
+ {/* ChromaDB Directory */} +
+ + + setLocalSettings((prev) => ({ ...prev, chromaDir: e.target.value })) + } + placeholder="./data/chromadb" + className="input-field" + /> +

+ Path to ChromaDB persistent storage on the server +

+
+
+ ) : ( +
+ +
+ )}
{/* Footer */} -
- - -
+ {activeTab === 'settings' && ( +
+ + +
+ )}
); diff --git a/frontend/src/components/chat/TokenUsagePanel.tsx b/frontend/src/components/chat/TokenUsagePanel.tsx new file mode 100644 index 0000000..dcfefa2 --- /dev/null +++ b/frontend/src/components/chat/TokenUsagePanel.tsx @@ -0,0 +1,282 @@ +import { useState, useEffect } from 'react'; +import { RefreshCw, Trash2, BarChart3 } from 'lucide-react'; + +interface TokenUsageSummary { + promptTokens: number; + completionTokens: number; + totalTokens: number; + requestCount: number; +} + +interface ModelBreakdown { + [key: string]: { + promptTokens: number; + completionTokens: number; + totalTokens: number; + requestCount: number; + }; +} + +interface TokenUsageHistory { + timestamp: number; + conversationId: string; + model: string; + promptTokens: number; + completionTokens: number; + totalTokens: number; +} + +interface TokenUsageData { + summary: TokenUsageSummary; + modelBreakdown: ModelBreakdown; + history: TokenUsageHistory[]; +} + +type TimePeriod = 'all' | 'today' | 'week'; + +export default function TokenUsagePanel() { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [period, setPeriod] = useState('all'); + const [expandedModel, setExpandedModel] = useState(null); + + const fetchData = async () => { + setLoading(true); + try { + const res = await fetch(`/api/token-usage?period=${period}`); + const result = await res.json(); + setData(result); + } catch (err) { + console.error('Failed to fetch token usage:', err); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchData(); + }, [period]); + + const handleClear = async () => { + if (!confirm('Clear all token usage data?')) return; + try { + await fetch('/api/token-usage', { method: 'DELETE' }); + setData(null); + fetchData(); + } catch (err) { + console.error('Failed to clear token usage:', err); + } + }; + + const formatNumber = (n: number) => n.toLocaleString(); + + const formatTime = (timestamp: number) => { + const date = new Date(timestamp); + return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); + }; + + return ( +
+ {/* Header */} +
+
+ +

Token Usage

+
+
+ + +
+
+ + {/* Period filter */} +
+ {(['all', 'today', 'week'] as TimePeriod[]).map((p) => { + const labels = { all: 'All', today: 'Today', week: 'Week' }; + return ( + + ); + })} +
+ + {/* Summary */} + {data && data.summary.requestCount > 0 ? ( +
+
+

Total Tokens

+

+ {formatNumber(data.summary.totalTokens)} +

+
+
+

Requests

+

+ {data.summary.requestCount} +

+
+
+

Prompt Tokens

+

+ {formatNumber(data.summary.promptTokens)} +

+
+
+

Completion Tokens

+

+ {formatNumber(data.summary.completionTokens)} +

+
+
+ ) : ( +
+ {loading ? 'Loading...' : 'No token usage data yet'} +
+ )} + + {/* Model breakdown */} + {data && Object.keys(data.modelBreakdown).length > 0 && ( +
+

By Model

+ {Object.entries(data.modelBreakdown) + .sort((a, b) => b[1].totalTokens - a[1].totalTokens) + .map(([model, stats]) => ( +
+ + + {expandedModel === model && ( +
+ {/* Simple bar chart */} +
+
+ Prompt +
+
0 + ? (stats.promptTokens / stats.totalTokens) * 100 + : 0 + }%`, + }} + /> +
+ + {formatNumber(stats.promptTokens)} + +
+
+ Completion +
+
0 + ? (stats.completionTokens / stats.totalTokens) * 100 + : 0 + }%`, + }} + /> +
+ + {formatNumber(stats.completionTokens)} + +
+
+ + {/* Show recent history for this model */} + {data.history.filter((h) => h.model === model).length > 0 && ( +
+

Recent requests

+
+ {data.history + .filter((h) => h.model === model) + .slice(-5) + .reverse() + .map((entry, i) => ( +
+ {formatTime(entry.timestamp)} + + {formatNumber(entry.totalTokens)} tokens + +
+ ))} +
+
+ )} +
+ )} +
+ ))} +
+ )} + + {/* History timeline */} + {data && data.history.length > 0 && ( +
+

Recent History

+ {data.history.slice(-10).reverse().map((entry, i) => ( +
+ + {formatTime(entry.timestamp)} + + {entry.model} + + {formatNumber(entry.totalTokens)} + +
+ ))} +
+ )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/knowledge/KnowledgePanel.tsx b/frontend/src/components/knowledge/KnowledgePanel.tsx index 8e9e0e6..238da89 100644 --- a/frontend/src/components/knowledge/KnowledgePanel.tsx +++ b/frontend/src/components/knowledge/KnowledgePanel.tsx @@ -1,7 +1,15 @@ -import { useState, useEffect } from 'react'; -import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp } from 'lucide-react'; +import { useState, useEffect, useRef } from 'react'; +import { X, Plus, Trash2, Upload, BookOpen, FileText, Database, ChevronDown, ChevronUp, FolderOpen, AlertCircle } from 'lucide-react'; import { useChatStore } from '@/stores/chatStore'; +interface FileResult { + id: string; + originalName: string; + size: number; + chunkCount?: number; +} + + export default function KnowledgePanel() { const { settings, toggleKnowledge, isKnowledgeOpen, setKnowledgeBases, knowledgeBases, removeKnowledgeBase } = useChatStore(); const [newName, setNewName] = useState(''); @@ -10,6 +18,10 @@ export default function KnowledgePanel() { const [expandedBase, setExpandedBase] = useState(null); const [uploadingBase, setUploadingBase] = useState(null); const [loading, setLoading] = useState(false); + const [folderLoading, setFolderLoading] = useState(false); + const [error, setError] = useState(null); + const fileInputRef = useRef(null); + const folderInputRef = useRef(null); useEffect(() => { if (isKnowledgeOpen) loadBases(); @@ -17,11 +29,15 @@ export default function KnowledgePanel() { const loadBases = async () => { try { + setError(null); const res = await fetch('/api/knowledge'); + if (!res.ok) throw new Error('Failed to load'); const data = await res.json(); setKnowledgeBases(data.bases || []); } catch (err) { console.error('Failed to load knowledge bases:', err); + setError('Could not connect to knowledge base service. Is the backend running?'); + setKnowledgeBases([]); } }; @@ -35,12 +51,13 @@ export default function KnowledgePanel() { body: JSON.stringify({ name: newName.trim(), description: newDesc.trim() }), }); const base = await res.json(); + if (base.error) throw new Error(base.error); setKnowledgeBases([...knowledgeBases, base]); setNewName(''); setNewDesc(''); setIsCreating(false); - } catch (err) { - alert('Failed to create knowledge base'); + } catch (err: any) { + setError(err.message || 'Failed to create knowledge base'); } finally { setLoading(false); } @@ -52,7 +69,7 @@ export default function KnowledgePanel() { await fetch(`/api/knowledge/${id}`, { method: 'DELETE' }); removeKnowledgeBase(id); } catch (err) { - alert('Failed to delete knowledge base'); + setError('Failed to delete knowledge base'); } }; @@ -60,6 +77,7 @@ export default function KnowledgePanel() { if (!e.target.files?.[0]) return; const file = e.target.files[0]; setUploadingBase(baseId); + setError(null); try { const formData = new FormData(); @@ -76,7 +94,6 @@ export default function KnowledgePanel() { if (!res.ok) throw new Error('Upload failed'); const data = await res.json(); - // Update local state const updated = knowledgeBases.map((b) => b.id === baseId ? { ...b, files: [...b.files, data.file], documentCount: (b.documentCount || 0) + data.chunks } @@ -84,13 +101,68 @@ export default function KnowledgePanel() { ); setKnowledgeBases(updated); } catch (err) { - alert('Failed to upload file. Make sure API settings are configured.'); + setError('Failed to upload file. Make sure API settings are configured.'); } finally { setUploadingBase(null); e.target.value = ''; } }; + const handleUploadFolder = async (baseId: string, e: React.ChangeEvent) => { + const files = e.target.files; + if (!files || files.length === 0) return; + + setFolderLoading(true); + setError(null); + + // For browser folder upload, we can't get the actual folder path. + // Instead, upload all files from the file picker using webkitdirectory. + const formData = new FormData(); + Array.from(files).forEach((f) => formData.append('files', f)); + formData.append('apiUrl', settings.apiUrl); + formData.append('apiKey', settings.apiKey); + formData.append('embeddingModel', settings.embeddingModel); + + try { + // Use a batch upload approach — upload files one by one + const uploaded: FileResult[] = []; + const errors: string[] = []; + + for (const file of Array.from(files)) { + const singleForm = new FormData(); + singleForm.append('file', file); + singleForm.append('apiUrl', settings.apiUrl); + singleForm.append('apiKey', settings.apiKey); + singleForm.append('embeddingModel', settings.embeddingModel); + + try { + const res = await fetch(`/api/knowledge/${baseId}/files`, { + method: 'POST', + body: singleForm, + }); + if (!res.ok) throw new Error('Failed'); + const data = await res.json(); + uploaded.push(data.file); + } catch { + errors.push(file.name); + } + } + + if (uploaded.length > 0) { + await loadBases(); + } + + if (errors.length > 0) { + setError(`${errors.length} files failed to upload out of ${files.length}`); + } + } catch (err) { + setError('Folder upload failed'); + } finally { + setFolderLoading(false); + e.target.value = ''; + } + }; + const handleDeleteFile = async (baseId: string, fileId: string) => { try { await fetch(`/api/knowledge/${baseId}/files/${fileId}`, { method: 'DELETE' }); @@ -105,7 +177,7 @@ export default function KnowledgePanel() { ); setKnowledgeBases(updated); } catch (err) { - alert('Failed to delete file'); + setError('Failed to delete file'); } }; @@ -131,6 +203,17 @@ export default function KnowledgePanel() { {/* Content */}
+ {/* Error */} + {error && ( +
+ + {error} + +
+ )} + {/* Create new */} {!isCreating ? (
@@ -222,7 +305,7 @@ export default function KnowledgePanel() { )} {/* Files */} - {base.files.length > 0 && ( + {base.files && base.files.length > 0 && (
{base.files.map((file) => (
- ({file.chunkCount} chunks) + ({file.chunkCount || 0} chunks)