voyageai-cli 1.23.1 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +64 -0
  2. package/package.json +1 -1
  3. package/src/cli.js +2 -0
  4. package/src/commands/about.js +1 -1
  5. package/src/commands/bug.js +1 -1
  6. package/src/commands/mcp-server.js +74 -0
  7. package/src/commands/playground.js +31 -0
  8. package/src/commands/scaffold.js +23 -1
  9. package/src/commands/workflow.js +336 -0
  10. package/src/lib/explanations.js +53 -0
  11. package/src/lib/scaffold-structure.js +8 -9
  12. package/src/lib/telemetry.js +1 -1
  13. package/src/lib/template-engine.js +240 -0
  14. package/src/lib/templates/nextjs/README.md.tpl +78 -55
  15. package/src/lib/templates/nextjs/favicon.svg.tpl +11 -0
  16. package/src/lib/templates/nextjs/footer.jsx.tpl +49 -0
  17. package/src/lib/templates/nextjs/layout.jsx.tpl +16 -10
  18. package/src/lib/templates/nextjs/lib-mongo.js.tpl +5 -5
  19. package/src/lib/templates/nextjs/lib-voyage.js.tpl +13 -8
  20. package/src/lib/templates/nextjs/navbar.jsx.tpl +98 -0
  21. package/src/lib/templates/nextjs/page-home.jsx.tpl +201 -0
  22. package/src/lib/templates/nextjs/page-search.jsx.tpl +184 -82
  23. package/src/lib/templates/nextjs/theme-registry.jsx.tpl +51 -0
  24. package/src/lib/templates/nextjs/theme.js.tpl +138 -65
  25. package/src/lib/templates/nextjs/vai-logo-256.png +0 -0
  26. package/src/lib/workflow-utils.js +65 -0
  27. package/src/lib/workflow.js +1259 -0
  28. package/src/mcp/install.js +201 -0
  29. package/src/mcp/tools/management.js +1 -60
  30. package/src/playground/icons/dark/128.png +0 -0
  31. package/src/playground/icons/dark/16.png +0 -0
  32. package/src/playground/icons/dark/256.png +0 -0
  33. package/src/playground/icons/dark/32.png +0 -0
  34. package/src/playground/icons/dark/64.png +0 -0
  35. package/src/playground/icons/light/128.png +0 -0
  36. package/src/playground/icons/light/16.png +0 -0
  37. package/src/playground/icons/light/256.png +0 -0
  38. package/src/playground/icons/light/32.png +0 -0
  39. package/src/playground/icons/light/64.png +0 -0
  40. package/src/playground/icons/watermark.png +0 -0
  41. package/src/playground/index.html +125 -73
  42. package/src/workflows/consistency-check.json +64 -0
  43. package/src/workflows/cost-analysis.json +69 -0
  44. package/src/workflows/multi-collection-search.json +80 -0
  45. package/src/workflows/research-and-summarize.json +46 -0
  46. package/src/workflows/smart-ingest.json +63 -0
@@ -6,16 +6,22 @@
6
6
  * Dimensions: {{dimensions}}
7
7
  */
8
8
 
9
- const VOYAGE_API_URL = process.env.VOYAGE_API_URL || 'https://api.voyageai.com/v1';
10
- const VOYAGE_API_KEY = process.env.VOYAGE_API_KEY;
9
+ function getApiUrl() {
10
+ return process.env.VOYAGE_API_URL || 'https://api.voyageai.com/v1';
11
+ }
12
+
13
+ function getApiKey() {
14
+ const key = process.env.VOYAGE_API_KEY;
15
+ if (!key) throw new Error('VOYAGE_API_KEY environment variable is required');
16
+ return key;
17
+ }
11
18
 
12
19
  /**
13
20
  * Generate embeddings for text(s) using Voyage AI.
14
21
  */
15
22
  export async function embed(input, options = {}) {
16
- if (!VOYAGE_API_KEY) {
17
- throw new Error('VOYAGE_API_KEY environment variable is required');
18
- }
23
+ const VOYAGE_API_URL = getApiUrl();
24
+ const VOYAGE_API_KEY = getApiKey();
19
25
 
20
26
  const texts = Array.isArray(input) ? input : [input];
21
27
 
@@ -66,9 +72,8 @@ export async function embedDocuments(documents, options = {}) {
66
72
  * Rerank documents by relevance to a query.
67
73
  */
68
74
  export async function rerank(query, documents, options = {}) {
69
- if (!VOYAGE_API_KEY) {
70
- throw new Error('VOYAGE_API_KEY environment variable is required');
71
- }
75
+ const VOYAGE_API_URL = getApiUrl();
76
+ const VOYAGE_API_KEY = getApiKey();
72
77
 
73
78
  const response = await fetch(`${VOYAGE_API_URL}/rerank`, {
74
79
  method: 'POST',
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Branded AppBar with dark mode toggle
3
+ * Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import {
9
+ AppBar,
10
+ Toolbar,
11
+ Typography,
12
+ IconButton,
13
+ Box,
14
+ Chip,
15
+ Tooltip,
16
+ useTheme,
17
+ } from '@mui/material';
18
+ import DarkModeIcon from '@mui/icons-material/DarkMode';
19
+ import LightModeIcon from '@mui/icons-material/LightMode';
20
+ import GitHubIcon from '@mui/icons-material/GitHub';
21
+ import { useColorMode } from './ThemeRegistry';
22
+
23
+ export default function Navbar() {
24
+ const theme = useTheme();
25
+ const { mode, toggle } = useColorMode();
26
+
27
+ return (
28
+ <AppBar position="sticky" elevation={0} color="transparent">
29
+ <Toolbar sx={{ gap: 1.5 }}>
30
+ {/* Logo / wordmark */}
31
+ <Box
32
+ component="img"
33
+ src="/vai-logo.png"
34
+ alt="vai logo"
35
+ sx={{ height: 28, width: 28 }}
36
+ />
37
+ <Typography
38
+ variant="h6"
39
+ sx={{
40
+ fontWeight: 800,
41
+ letterSpacing: '-0.02em',
42
+ background: `linear-gradient(135deg, ${theme.palette.primary.main} 0%, ${theme.palette.primary.dark} 100%)`,
43
+ WebkitBackgroundClip: 'text',
44
+ WebkitTextFillColor: 'transparent',
45
+ }}
46
+ >
47
+ {{projectName}}
48
+ </Typography>
49
+
50
+ <Chip
51
+ label="vai"
52
+ size="small"
53
+ sx={{
54
+ fontWeight: 700,
55
+ fontSize: '0.65rem',
56
+ height: 20,
57
+ bgcolor: 'primary.main',
58
+ color: 'primary.contrastText',
59
+ }}
60
+ />
61
+
62
+ <Box sx={{ flex: 1 }} />
63
+
64
+ {/* Model badge */}
65
+ <Chip
66
+ label="{{model}}"
67
+ size="small"
68
+ variant="outlined"
69
+ sx={{
70
+ display: { xs: 'none', sm: 'flex' },
71
+ fontFamily: 'monospace',
72
+ fontSize: '0.7rem',
73
+ }}
74
+ />
75
+
76
+ {/* GitHub link */}
77
+ <Tooltip title="View on GitHub">
78
+ <IconButton
79
+ size="small"
80
+ href="https://github.com/mrlynn/voyageai-cli"
81
+ target="_blank"
82
+ rel="noopener"
83
+ sx={{ color: 'text.secondary' }}
84
+ >
85
+ <GitHubIcon fontSize="small" />
86
+ </IconButton>
87
+ </Tooltip>
88
+
89
+ {/* Dark mode toggle */}
90
+ <Tooltip title={mode === 'dark' ? 'Light mode' : 'Dark mode'}>
91
+ <IconButton size="small" onClick={toggle} sx={{ color: 'text.secondary' }}>
92
+ {mode === 'dark' ? <LightModeIcon fontSize="small" /> : <DarkModeIcon fontSize="small" />}
93
+ </IconButton>
94
+ </Tooltip>
95
+ </Toolbar>
96
+ </AppBar>
97
+ );
98
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Landing Page — branded hero with quick stats
3
+ * Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
+ */
5
+
6
+ 'use client';
7
+
8
+ import {
9
+ Box,
10
+ Container,
11
+ Typography,
12
+ Button,
13
+ Grid,
14
+ Card,
15
+ CardContent,
16
+ Stack,
17
+ Chip,
18
+ useTheme,
19
+ alpha,
20
+ } from '@mui/material';
21
+ import SearchIcon from '@mui/icons-material/Search';
22
+ import StorageIcon from '@mui/icons-material/Storage';
23
+ import BoltIcon from '@mui/icons-material/Bolt';
24
+ import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
25
+ import Navbar from '@/components/Navbar';
26
+ import Footer from '@/components/Footer';
27
+
28
+ const features = [
29
+ {
30
+ icon: <AutoAwesomeIcon />,
31
+ title: 'Voyage AI Embeddings',
32
+ desc: 'State-of-the-art {{model}} model with {{dimensions}}-dimensional vectors for precise semantic understanding.',
33
+ },
34
+ {
35
+ icon: <StorageIcon />,
36
+ title: 'MongoDB Atlas Vector Search',
37
+ desc: 'Lightning-fast approximate nearest-neighbor search on your {{db}}.{{collection}} collection.',
38
+ },
39
+ {
40
+ icon: <BoltIcon />,
41
+ title: 'Full RAG Pipeline',
42
+ desc: 'Ingest, chunk, embed, store, and search — a complete retrieval-augmented generation stack out of the box.',
43
+ },
44
+ ];
45
+
46
+ export default function HomePage() {
47
+ const theme = useTheme();
48
+
49
+ return (
50
+ <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
51
+ <Navbar />
52
+
53
+ {/* ── Hero ──────────────────────────────── */}
54
+ <Box
55
+ sx={{
56
+ position: 'relative',
57
+ overflow: 'hidden',
58
+ pt: { xs: 8, md: 14 },
59
+ pb: { xs: 8, md: 12 },
60
+ }}
61
+ >
62
+ {/* gradient accent */}
63
+ <Box
64
+ sx={{
65
+ position: 'absolute',
66
+ top: -120,
67
+ right: -120,
68
+ width: 480,
69
+ height: 480,
70
+ borderRadius: '50%',
71
+ background: `radial-gradient(circle, ${alpha(theme.palette.primary.main, 0.15)} 0%, transparent 70%)`,
72
+ pointerEvents: 'none',
73
+ }}
74
+ />
75
+
76
+ <Container maxWidth="md" sx={{ position: 'relative', textAlign: 'center' }}>
77
+ <Box
78
+ component="img"
79
+ src="/vai-logo.png"
80
+ alt="vai logo"
81
+ sx={{ width: 64, height: 64, mb: 2, mx: 'auto', display: 'block' }}
82
+ />
83
+
84
+ <Chip
85
+ label="Scaffolded with vai"
86
+ size="small"
87
+ color="success"
88
+ sx={{ mb: 3 }}
89
+ />
90
+
91
+ <Typography variant="h2" component="h1" gutterBottom>
92
+ <Box
93
+ component="span"
94
+ sx={{
95
+ background: `linear-gradient(135deg, ${theme.palette.primary.main}, ${theme.palette.primary.light})`,
96
+ WebkitBackgroundClip: 'text',
97
+ WebkitTextFillColor: 'transparent',
98
+ }}
99
+ >
100
+ {{projectName}}
101
+ </Box>
102
+ </Typography>
103
+
104
+ <Typography variant="h5" component="p" color="text.secondary" gutterBottom>
105
+ Semantic search powered by Voyage AI
106
+ </Typography>
107
+
108
+ <Typography
109
+ variant="h6"
110
+ color="text.secondary"
111
+ sx={{ maxWidth: 560, mx: 'auto', mb: 4, fontWeight: 400 }}
112
+ >
113
+ Ask questions in natural language and find the most relevant documents
114
+ using vector embeddings{{#if rerank}} with reranking{{/if}}.
115
+ </Typography>
116
+
117
+ <Stack direction="row" spacing={2} justifyContent="center">
118
+ <Button
119
+ variant="contained"
120
+ size="large"
121
+ href="/search"
122
+ startIcon={<SearchIcon />}
123
+ sx={{ px: 4 }}
124
+ >
125
+ Try Search
126
+ </Button>
127
+ <Button
128
+ variant="outlined"
129
+ size="large"
130
+ href="/api/health"
131
+ sx={{ px: 4 }}
132
+ >
133
+ API Health
134
+ </Button>
135
+ </Stack>
136
+ </Container>
137
+ </Box>
138
+
139
+ {/* ── Feature cards ────────────────────── */}
140
+ <Container maxWidth="lg" sx={{ pb: 10 }}>
141
+ <Grid container spacing={3}>
142
+ {features.map((f, i) => (
143
+ <Grid item xs={12} md={4} key={i}>
144
+ <Card sx={{ height: '100%' }}>
145
+ <CardContent sx={{ p: 3 }}>
146
+ <Box
147
+ sx={{
148
+ width: 48,
149
+ height: 48,
150
+ borderRadius: 2,
151
+ display: 'flex',
152
+ alignItems: 'center',
153
+ justifyContent: 'center',
154
+ mb: 2,
155
+ bgcolor: alpha(theme.palette.primary.main, 0.1),
156
+ color: 'primary.main',
157
+ }}
158
+ >
159
+ {f.icon}
160
+ </Box>
161
+ <Typography variant="h6" gutterBottom>
162
+ {f.title}
163
+ </Typography>
164
+ <Typography variant="body2" color="text.secondary">
165
+ {f.desc}
166
+ </Typography>
167
+ </CardContent>
168
+ </Card>
169
+ </Grid>
170
+ ))}
171
+ </Grid>
172
+
173
+ {/* ── Config overview ────────────────── */}
174
+ <Box
175
+ sx={{
176
+ mt: 6,
177
+ p: 3,
178
+ borderRadius: 3,
179
+ bgcolor: theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.03)' : 'grey.50',
180
+ border: `1px solid ${theme.palette.divider}`,
181
+ }}
182
+ >
183
+ <Typography variant="overline" color="text.secondary" gutterBottom>
184
+ Configuration
185
+ </Typography>
186
+ <Stack direction="row" flexWrap="wrap" gap={1} sx={{ mt: 1 }}>
187
+ <Chip label="Model: {{model}}" size="small" variant="outlined" sx={{ fontFamily: 'monospace' }} />
188
+ <Chip label="Dims: {{dimensions}}" size="small" variant="outlined" sx={{ fontFamily: 'monospace' }} />
189
+ <Chip label="DB: {{db}}.{{collection}}" size="small" variant="outlined" sx={{ fontFamily: 'monospace' }} />
190
+ <Chip label="Index: {{index}}" size="small" variant="outlined" sx={{ fontFamily: 'monospace' }} />
191
+ {{#if rerank}}
192
+ <Chip label="Rerank: {{rerankModel}}" size="small" color="success" sx={{ fontFamily: 'monospace' }} />
193
+ {{/if}}
194
+ </Stack>
195
+ </Box>
196
+ </Container>
197
+
198
+ <Footer />
199
+ </Box>
200
+ );
201
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Search Page (MUI)
2
+ * Search Page — branded semantic search UI
3
3
  * Generated by vai v{{vaiVersion}} on {{generatedAt}}
4
4
  */
5
5
 
@@ -14,21 +14,52 @@ import {
14
14
  Card,
15
15
  CardContent,
16
16
  Typography,
17
- List,
18
- ListItem,
19
17
  Chip,
20
18
  CircularProgress,
21
19
  Alert,
22
20
  InputAdornment,
21
+ Stack,
22
+ LinearProgress,
23
+ Tooltip,
24
+ IconButton,
25
+ Fade,
26
+ useTheme,
27
+ alpha,
23
28
  } from '@mui/material';
24
29
  import SearchIcon from '@mui/icons-material/Search';
30
+ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
31
+ import ContentCopyIcon from '@mui/icons-material/ContentCopy';
32
+ import Navbar from '@/components/Navbar';
33
+ import Footer from '@/components/Footer';
34
+
35
+ function ScoreBar({ score }) {
36
+ const pct = Math.round(score * 100);
37
+ const color = pct >= 80 ? 'success' : pct >= 50 ? 'primary' : 'warning';
38
+ return (
39
+ <Tooltip title={`Relevance: ${score.toFixed(4)}`}>
40
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1, minWidth: 120 }}>
41
+ <LinearProgress
42
+ variant="determinate"
43
+ value={pct}
44
+ color={color}
45
+ sx={{ flex: 1, height: 6, borderRadius: 3 }}
46
+ />
47
+ <Typography variant="caption" fontWeight={700} sx={{ fontFamily: 'monospace', minWidth: 36 }}>
48
+ {pct}%
49
+ </Typography>
50
+ </Box>
51
+ </Tooltip>
52
+ );
53
+ }
25
54
 
26
55
  export default function SearchPage() {
56
+ const theme = useTheme();
27
57
  const [query, setQuery] = useState('');
28
58
  const [results, setResults] = useState([]);
29
59
  const [loading, setLoading] = useState(false);
30
60
  const [error, setError] = useState(null);
31
61
  const [meta, setMeta] = useState(null);
62
+ const [copied, setCopied] = useState(null);
32
63
 
33
64
  const handleSearch = async (e) => {
34
65
  e.preventDefault();
@@ -60,88 +91,159 @@ export default function SearchPage() {
60
91
  }
61
92
  };
62
93
 
94
+ const copyText = (text, idx) => {
95
+ navigator.clipboard.writeText(text);
96
+ setCopied(idx);
97
+ setTimeout(() => setCopied(null), 1500);
98
+ };
99
+
63
100
  return (
64
- <Container maxWidth="md" sx={{ py: 4 }}>
65
- <Typography variant="h4" component="h1" gutterBottom>
66
- Semantic Search
67
- </Typography>
68
-
69
- <Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
70
- Search your documents using {{model}} embeddings
71
- {{#if rerank}} with {{rerankModel}} reranking{{/if}}.
72
- </Typography>
73
-
74
- <Box component="form" onSubmit={handleSearch} sx={{ mb: 4 }}>
75
- <TextField
76
- fullWidth
77
- value={query}
78
- onChange={(e) => setQuery(e.target.value)}
79
- placeholder="Enter your search query..."
80
- variant="outlined"
81
- InputProps={{
82
- startAdornment: (
83
- <InputAdornment position="start">
84
- <SearchIcon />
85
- </InputAdornment>
86
- ),
87
- endAdornment: (
88
- <InputAdornment position="end">
89
- <Button
90
- type="submit"
91
- variant="contained"
92
- disabled={loading || !query.trim()}
93
- >
94
- {loading ? <CircularProgress size={24} /> : 'Search'}
95
- </Button>
96
- </InputAdornment>
97
- ),
98
- }}
99
- />
100
- </Box>
101
+ <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
102
+ <Navbar />
103
+
104
+ <Container maxWidth="md" sx={{ py: 5, flex: 1 }}>
105
+ {/* Header */}
106
+ <Stack direction="row" alignItems="center" spacing={1} sx={{ mb: 1 }}>
107
+ <IconButton href="/" size="small" sx={{ color: 'text.secondary' }}>
108
+ <ArrowBackIcon fontSize="small" />
109
+ </IconButton>
110
+ <Typography variant="h4" component="h1" fontWeight={800}>
111
+ Semantic Search
112
+ </Typography>
113
+ </Stack>
101
114
 
102
- {error && (
103
- <Alert severity="error" sx={{ mb: 3 }}>
104
- {error}
105
- </Alert>
106
- )}
107
-
108
- {meta && (
109
- <Box sx={{ mb: 2, display: 'flex', gap: 1 }}>
110
- <Chip label={`${results.length} results`} size="small" />
111
- <Chip label={`${meta.took}ms`} size="small" variant="outlined" />
112
- <Chip label={meta.model} size="small" variant="outlined" />
115
+ <Typography variant="subtitle1" sx={{ mb: 4, pl: 5.5 }}>
116
+ Using <strong>{{model}}</strong> embeddings{{#if rerank}} with <strong>{{rerankModel}}</strong> reranking{{/if}}
117
+ </Typography>
118
+
119
+ {/* Search form */}
120
+ <Box component="form" onSubmit={handleSearch} sx={{ mb: 4 }}>
121
+ <TextField
122
+ fullWidth
123
+ value={query}
124
+ onChange={(e) => setQuery(e.target.value)}
125
+ placeholder="Ask a question in natural language…"
126
+ variant="outlined"
127
+ autoFocus
128
+ InputProps={{
129
+ startAdornment: (
130
+ <InputAdornment position="start">
131
+ <SearchIcon sx={{ color: 'primary.main' }} />
132
+ </InputAdornment>
133
+ ),
134
+ endAdornment: (
135
+ <InputAdornment position="end">
136
+ <Button
137
+ type="submit"
138
+ variant="contained"
139
+ disabled={loading || !query.trim()}
140
+ sx={{ minWidth: 100 }}
141
+ >
142
+ {loading ? <CircularProgress size={22} color="inherit" /> : 'Search'}
143
+ </Button>
144
+ </InputAdornment>
145
+ ),
146
+ sx: { pr: 1 },
147
+ }}
148
+ />
113
149
  </Box>
114
- )}
115
-
116
- <List>
117
- {results.map((result, index) => (
118
- <ListItem key={index} sx={{ px: 0 }}>
119
- <Card sx={{ width: '100%' }}>
120
- <CardContent>
121
- <Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
122
- <Typography variant="subtitle2" color="text.secondary">
123
- {result.metadata?.source || 'Document'}
150
+
151
+ {/* Error */}
152
+ {error && (
153
+ <Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
154
+ {error}
155
+ </Alert>
156
+ )}
157
+
158
+ {/* Meta chips */}
159
+ {meta && (
160
+ <Stack direction="row" spacing={1} sx={{ mb: 3 }} flexWrap="wrap">
161
+ <Chip label={`${results.length} results`} size="small" color="primary" />
162
+ <Chip label={`${meta.took} ms`} size="small" variant="outlined" />
163
+ <Chip label={meta.model} size="small" variant="outlined" sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }} />
164
+ {{#if rerank}}
165
+ <Chip label={`reranked: ${meta.rerankModel}`} size="small" color="success" sx={{ fontFamily: 'monospace', fontSize: '0.7rem' }} />
166
+ {{/if}}
167
+ </Stack>
168
+ )}
169
+
170
+ {/* Results */}
171
+ <Stack spacing={2}>
172
+ {results.map((result, index) => (
173
+ <Fade in key={index} timeout={200 + index * 80}>
174
+ <Card>
175
+ <CardContent sx={{ p: 3, '&:last-child': { pb: 3 } }}>
176
+ <Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 1.5 }}>
177
+ <Stack direction="row" alignItems="center" spacing={1}>
178
+ <Chip
179
+ label={`#${index + 1}`}
180
+ size="small"
181
+ sx={{
182
+ fontWeight: 700,
183
+ bgcolor: alpha(theme.palette.primary.main, 0.1),
184
+ color: 'primary.dark',
185
+ }}
186
+ />
187
+ {result.metadata?.source && (
188
+ <Typography variant="caption" color="text.secondary" fontFamily="monospace">
189
+ {result.metadata.source}
190
+ </Typography>
191
+ )}
192
+ </Stack>
193
+ <ScoreBar score={result.score} />
194
+ </Stack>
195
+
196
+ <Typography
197
+ variant="body2"
198
+ sx={{
199
+ whiteSpace: 'pre-wrap',
200
+ lineHeight: 1.7,
201
+ color: 'text.primary',
202
+ }}
203
+ >
204
+ {result.text}
124
205
  </Typography>
125
- <Chip
126
- label={`Score: ${result.score.toFixed(3)}`}
127
- size="small"
128
- color={result.score > 0.8 ? 'success' : 'default'}
129
- />
130
- </Box>
131
- <Typography variant="body2" sx={{ whiteSpace: 'pre-wrap' }}>
132
- {result.text}
133
- </Typography>
134
- </CardContent>
135
- </Card>
136
- </ListItem>
137
- ))}
138
- </List>
139
-
140
- {results.length === 0 && !loading && query && !error && (
141
- <Typography color="text.secondary" align="center">
142
- No results found for "{query}"
143
- </Typography>
144
- )}
145
- </Container>
206
+
207
+ <Stack direction="row" justifyContent="flex-end" sx={{ mt: 1.5 }}>
208
+ <Tooltip title={copied === index ? 'Copied!' : 'Copy text'}>
209
+ <IconButton size="small" onClick={() => copyText(result.text, index)}>
210
+ <ContentCopyIcon fontSize="small" />
211
+ </IconButton>
212
+ </Tooltip>
213
+ </Stack>
214
+ </CardContent>
215
+ </Card>
216
+ </Fade>
217
+ ))}
218
+ </Stack>
219
+
220
+ {/* Empty state */}
221
+ {results.length === 0 && !loading && query && !error && (
222
+ <Box sx={{ textAlign: 'center', py: 8 }}>
223
+ <Typography variant="h6" color="text.secondary" gutterBottom>
224
+ No results found
225
+ </Typography>
226
+ <Typography variant="body2" color="text.secondary">
227
+ Try rephrasing your query or ingest some documents first.
228
+ </Typography>
229
+ </Box>
230
+ )}
231
+
232
+ {/* Initial state */}
233
+ {!meta && !loading && !error && (
234
+ <Box sx={{ textAlign: 'center', py: 8 }}>
235
+ <SearchIcon sx={{ fontSize: 48, color: 'text.disabled', mb: 2 }} />
236
+ <Typography variant="h6" color="text.secondary" gutterBottom>
237
+ Start searching
238
+ </Typography>
239
+ <Typography variant="body2" color="text.secondary">
240
+ Enter a natural-language query to find semantically similar documents.
241
+ </Typography>
242
+ </Box>
243
+ )}
244
+ </Container>
245
+
246
+ <Footer />
247
+ </Box>
146
248
  );
147
249
  }