@gmickel/gno 0.3.5 → 0.5.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.
- package/README.md +74 -7
- package/package.json +30 -1
- package/src/cli/commands/ask.ts +12 -187
- package/src/cli/commands/embed.ts +10 -4
- package/src/cli/commands/models/pull.ts +9 -4
- package/src/cli/commands/serve.ts +19 -0
- package/src/cli/commands/vsearch.ts +5 -2
- package/src/cli/program.ts +28 -0
- package/src/config/types.ts +11 -6
- package/src/llm/registry.ts +3 -1
- package/src/mcp/tools/vsearch.ts +5 -2
- package/src/pipeline/answer.ts +224 -0
- package/src/pipeline/contextual.ts +57 -0
- package/src/pipeline/expansion.ts +49 -31
- package/src/pipeline/explain.ts +11 -3
- package/src/pipeline/fusion.ts +20 -9
- package/src/pipeline/hybrid.ts +57 -40
- package/src/pipeline/index.ts +7 -0
- package/src/pipeline/rerank.ts +55 -27
- package/src/pipeline/types.ts +0 -3
- package/src/pipeline/vsearch.ts +3 -2
- package/src/serve/CLAUDE.md +91 -0
- package/src/serve/bunfig.toml +2 -0
- package/src/serve/context.ts +181 -0
- package/src/serve/index.ts +7 -0
- package/src/serve/public/app.tsx +56 -0
- package/src/serve/public/components/ai-elements/code-block.tsx +176 -0
- package/src/serve/public/components/ai-elements/conversation.tsx +98 -0
- package/src/serve/public/components/ai-elements/inline-citation.tsx +285 -0
- package/src/serve/public/components/ai-elements/loader.tsx +96 -0
- package/src/serve/public/components/ai-elements/message.tsx +443 -0
- package/src/serve/public/components/ai-elements/prompt-input.tsx +1421 -0
- package/src/serve/public/components/ai-elements/sources.tsx +75 -0
- package/src/serve/public/components/ai-elements/suggestion.tsx +51 -0
- package/src/serve/public/components/preset-selector.tsx +403 -0
- package/src/serve/public/components/ui/badge.tsx +46 -0
- package/src/serve/public/components/ui/button-group.tsx +82 -0
- package/src/serve/public/components/ui/button.tsx +62 -0
- package/src/serve/public/components/ui/card.tsx +92 -0
- package/src/serve/public/components/ui/carousel.tsx +244 -0
- package/src/serve/public/components/ui/collapsible.tsx +31 -0
- package/src/serve/public/components/ui/command.tsx +181 -0
- package/src/serve/public/components/ui/dialog.tsx +141 -0
- package/src/serve/public/components/ui/dropdown-menu.tsx +255 -0
- package/src/serve/public/components/ui/hover-card.tsx +42 -0
- package/src/serve/public/components/ui/input-group.tsx +167 -0
- package/src/serve/public/components/ui/input.tsx +21 -0
- package/src/serve/public/components/ui/progress.tsx +28 -0
- package/src/serve/public/components/ui/scroll-area.tsx +56 -0
- package/src/serve/public/components/ui/select.tsx +188 -0
- package/src/serve/public/components/ui/separator.tsx +26 -0
- package/src/serve/public/components/ui/table.tsx +114 -0
- package/src/serve/public/components/ui/textarea.tsx +18 -0
- package/src/serve/public/components/ui/tooltip.tsx +59 -0
- package/src/serve/public/globals.css +226 -0
- package/src/serve/public/hooks/use-api.ts +112 -0
- package/src/serve/public/index.html +13 -0
- package/src/serve/public/pages/Ask.tsx +442 -0
- package/src/serve/public/pages/Browse.tsx +270 -0
- package/src/serve/public/pages/Dashboard.tsx +202 -0
- package/src/serve/public/pages/DocView.tsx +302 -0
- package/src/serve/public/pages/Search.tsx +335 -0
- package/src/serve/routes/api.ts +763 -0
- package/src/serve/server.ts +249 -0
- package/src/store/migrations/002-documents-fts.ts +40 -0
- package/src/store/migrations/index.ts +2 -1
- package/src/store/sqlite/adapter.ts +216 -33
- package/src/store/sqlite/fts5-snowball.ts +144 -0
- package/src/store/types.ts +33 -3
- package/src/store/vector/stats.ts +3 -0
- package/src/store/vector/types.ts +1 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { useCallback, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface ApiState<T> {
|
|
4
|
+
data: T | null;
|
|
5
|
+
loading: boolean;
|
|
6
|
+
error: string | null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Safely parse JSON response, checking Content-Type first.
|
|
11
|
+
*/
|
|
12
|
+
async function parseJsonSafe(
|
|
13
|
+
res: Response
|
|
14
|
+
): Promise<{ json: unknown; parseError: string | null }> {
|
|
15
|
+
const ct = res.headers.get('content-type') ?? '';
|
|
16
|
+
const isJson = ct.includes('application/json');
|
|
17
|
+
|
|
18
|
+
if (!isJson) {
|
|
19
|
+
// Non-JSON response - return text as error context
|
|
20
|
+
const text = await res.text();
|
|
21
|
+
return {
|
|
22
|
+
json: null,
|
|
23
|
+
parseError: text.slice(0, 200) || `Non-JSON response: ${res.status}`,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const json = await res.json();
|
|
29
|
+
return { json, parseError: null };
|
|
30
|
+
} catch {
|
|
31
|
+
return { json: null, parseError: 'Invalid JSON response' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function useApi<T>() {
|
|
36
|
+
const [state, setState] = useState<ApiState<T>>({
|
|
37
|
+
data: null,
|
|
38
|
+
loading: false,
|
|
39
|
+
error: null,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const request = useCallback(
|
|
43
|
+
async (endpoint: string, options?: RequestInit): Promise<T | null> => {
|
|
44
|
+
setState({ data: null, loading: true, error: null });
|
|
45
|
+
|
|
46
|
+
try {
|
|
47
|
+
const res = await fetch(endpoint, {
|
|
48
|
+
headers: { 'Content-Type': 'application/json' },
|
|
49
|
+
...options,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const { json, parseError } = await parseJsonSafe(res);
|
|
53
|
+
|
|
54
|
+
if (parseError) {
|
|
55
|
+
setState({ data: null, loading: false, error: parseError });
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!res.ok) {
|
|
60
|
+
const apiError = json as { error?: { message?: string } };
|
|
61
|
+
const msg =
|
|
62
|
+
apiError.error?.message || `Request failed: ${res.status}`;
|
|
63
|
+
setState({ data: null, loading: false, error: msg });
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setState({ data: json as T, loading: false, error: null });
|
|
68
|
+
return json as T;
|
|
69
|
+
} catch (err) {
|
|
70
|
+
const msg = err instanceof Error ? err.message : 'Network error';
|
|
71
|
+
setState({ data: null, loading: false, error: msg });
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
[]
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
return { ...state, request };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function apiFetch<T>(
|
|
82
|
+
endpoint: string,
|
|
83
|
+
options?: RequestInit
|
|
84
|
+
): Promise<{ data: T | null; error: string | null }> {
|
|
85
|
+
try {
|
|
86
|
+
const res = await fetch(endpoint, {
|
|
87
|
+
headers: { 'Content-Type': 'application/json' },
|
|
88
|
+
...options,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const { json, parseError } = await parseJsonSafe(res);
|
|
92
|
+
|
|
93
|
+
if (parseError) {
|
|
94
|
+
return { data: null, error: parseError };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!res.ok) {
|
|
98
|
+
const apiError = json as { error?: { message?: string } };
|
|
99
|
+
return {
|
|
100
|
+
data: null,
|
|
101
|
+
error: apiError.error?.message || `Request failed: ${res.status}`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { data: json as T, error: null };
|
|
106
|
+
} catch (err) {
|
|
107
|
+
return {
|
|
108
|
+
data: null,
|
|
109
|
+
error: err instanceof Error ? err.message : 'Network error',
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>GNO - Local Knowledge Index</title>
|
|
7
|
+
<link rel="stylesheet" href="./globals.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script type="module" src="./app.tsx"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ArrowLeft,
|
|
3
|
+
BookOpen,
|
|
4
|
+
CornerDownLeft,
|
|
5
|
+
FileText,
|
|
6
|
+
Sparkles,
|
|
7
|
+
} from 'lucide-react';
|
|
8
|
+
import { useEffect, useRef, useState } from 'react';
|
|
9
|
+
import { Loader } from '../components/ai-elements/loader';
|
|
10
|
+
import {
|
|
11
|
+
Source,
|
|
12
|
+
Sources,
|
|
13
|
+
SourcesContent,
|
|
14
|
+
SourcesTrigger,
|
|
15
|
+
} from '../components/ai-elements/sources';
|
|
16
|
+
import { Badge } from '../components/ui/badge';
|
|
17
|
+
import { Button } from '../components/ui/button';
|
|
18
|
+
import { Card, CardContent } from '../components/ui/card';
|
|
19
|
+
import { Textarea } from '../components/ui/textarea';
|
|
20
|
+
import { apiFetch } from '../hooks/use-api';
|
|
21
|
+
|
|
22
|
+
interface PageProps {
|
|
23
|
+
navigate: (to: string | number) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface Citation {
|
|
27
|
+
docid: string;
|
|
28
|
+
uri: string;
|
|
29
|
+
startLine?: number;
|
|
30
|
+
endLine?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface SearchResult {
|
|
34
|
+
docid: string;
|
|
35
|
+
uri: string;
|
|
36
|
+
title?: string;
|
|
37
|
+
snippet: string;
|
|
38
|
+
score: number;
|
|
39
|
+
snippetRange?: {
|
|
40
|
+
startLine: number;
|
|
41
|
+
endLine: number;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface AskResponse {
|
|
46
|
+
query: string;
|
|
47
|
+
mode: string;
|
|
48
|
+
queryLanguage: string;
|
|
49
|
+
answer?: string;
|
|
50
|
+
citations?: Citation[];
|
|
51
|
+
results: SearchResult[];
|
|
52
|
+
meta: {
|
|
53
|
+
expanded: boolean;
|
|
54
|
+
reranked: boolean;
|
|
55
|
+
vectorsUsed: boolean;
|
|
56
|
+
answerGenerated: boolean;
|
|
57
|
+
totalResults: number;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface Capabilities {
|
|
62
|
+
bm25: boolean;
|
|
63
|
+
vector: boolean;
|
|
64
|
+
hybrid: boolean;
|
|
65
|
+
answer: boolean;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface ConversationEntry {
|
|
69
|
+
id: string;
|
|
70
|
+
query: string;
|
|
71
|
+
response: AskResponse | null;
|
|
72
|
+
loading: boolean;
|
|
73
|
+
error?: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Render answer text with clickable citation badges.
|
|
78
|
+
* Citations like [1] become clickable to navigate to source.
|
|
79
|
+
*/
|
|
80
|
+
function renderAnswer(
|
|
81
|
+
answer: string,
|
|
82
|
+
citations: Citation[],
|
|
83
|
+
navigate: (to: string) => void
|
|
84
|
+
): React.ReactNode {
|
|
85
|
+
const parts: React.ReactNode[] = [];
|
|
86
|
+
let key = 0;
|
|
87
|
+
|
|
88
|
+
const citationRegex = /\[(\d+)\]/g;
|
|
89
|
+
let lastIndex = 0;
|
|
90
|
+
let match: RegExpExecArray | null;
|
|
91
|
+
|
|
92
|
+
// biome-ignore lint/suspicious/noAssignInExpressions: Standard regex match pattern
|
|
93
|
+
while ((match = citationRegex.exec(answer)) !== null) {
|
|
94
|
+
// Add text before citation
|
|
95
|
+
if (match.index > lastIndex) {
|
|
96
|
+
parts.push(answer.slice(lastIndex, match.index));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const citationNum = Number(match[1]);
|
|
100
|
+
const citation = citations[citationNum - 1];
|
|
101
|
+
|
|
102
|
+
if (citation) {
|
|
103
|
+
parts.push(
|
|
104
|
+
<button
|
|
105
|
+
className="mx-0.5 inline-flex items-center rounded bg-primary/20 px-1.5 py-0.5 font-mono text-primary text-xs transition-colors hover:bg-primary/30"
|
|
106
|
+
key={key++}
|
|
107
|
+
onClick={() =>
|
|
108
|
+
navigate(`/doc?uri=${encodeURIComponent(citation.uri)}`)
|
|
109
|
+
}
|
|
110
|
+
type="button"
|
|
111
|
+
>
|
|
112
|
+
{citationNum}
|
|
113
|
+
</button>
|
|
114
|
+
);
|
|
115
|
+
} else {
|
|
116
|
+
parts.push(match[0]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
lastIndex = match.index + match[0].length;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Add remaining text
|
|
123
|
+
if (lastIndex < answer.length) {
|
|
124
|
+
parts.push(answer.slice(lastIndex));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return parts;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export default function Ask({ navigate }: PageProps) {
|
|
131
|
+
const [query, setQuery] = useState('');
|
|
132
|
+
const [conversation, setConversation] = useState<ConversationEntry[]>([]);
|
|
133
|
+
const [capabilities, setCapabilities] = useState<Capabilities | null>(null);
|
|
134
|
+
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
135
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
136
|
+
|
|
137
|
+
// Fetch capabilities on mount
|
|
138
|
+
useEffect(() => {
|
|
139
|
+
async function fetchCapabilities() {
|
|
140
|
+
const { data } = await apiFetch<Capabilities>('/api/capabilities');
|
|
141
|
+
if (data) {
|
|
142
|
+
setCapabilities(data);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
fetchCapabilities();
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
// Scroll to bottom when conversation updates
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
|
151
|
+
}, []);
|
|
152
|
+
|
|
153
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
154
|
+
e.preventDefault();
|
|
155
|
+
if (!query.trim()) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const entryId = crypto.randomUUID();
|
|
160
|
+
const currentQuery = query.trim();
|
|
161
|
+
|
|
162
|
+
// Add entry to conversation
|
|
163
|
+
setConversation((prev) => [
|
|
164
|
+
...prev,
|
|
165
|
+
{ id: entryId, query: currentQuery, response: null, loading: true },
|
|
166
|
+
]);
|
|
167
|
+
setQuery('');
|
|
168
|
+
|
|
169
|
+
// Make API call
|
|
170
|
+
const { data, error } = await apiFetch<AskResponse>('/api/ask', {
|
|
171
|
+
method: 'POST',
|
|
172
|
+
body: JSON.stringify({ query: currentQuery, limit: 5 }),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Update conversation with response
|
|
176
|
+
setConversation((prev) =>
|
|
177
|
+
prev.map((entry) =>
|
|
178
|
+
entry.id === entryId
|
|
179
|
+
? {
|
|
180
|
+
...entry,
|
|
181
|
+
response: data ?? null,
|
|
182
|
+
loading: false,
|
|
183
|
+
error: error ?? undefined,
|
|
184
|
+
}
|
|
185
|
+
: entry
|
|
186
|
+
)
|
|
187
|
+
);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
191
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
handleSubmit(e);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
const answerAvailable = capabilities?.answer ?? false;
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className="flex min-h-screen flex-col">
|
|
201
|
+
{/* Header */}
|
|
202
|
+
<header className="glass sticky top-0 z-10 border-border/50 border-b">
|
|
203
|
+
<div className="flex items-center gap-4 px-8 py-4">
|
|
204
|
+
<Button
|
|
205
|
+
className="gap-2"
|
|
206
|
+
onClick={() => navigate(-1)}
|
|
207
|
+
size="sm"
|
|
208
|
+
variant="ghost"
|
|
209
|
+
>
|
|
210
|
+
<ArrowLeft className="size-4" />
|
|
211
|
+
Back
|
|
212
|
+
</Button>
|
|
213
|
+
<h1 className="font-semibold text-xl">Ask</h1>
|
|
214
|
+
{capabilities && (
|
|
215
|
+
<div className="ml-auto flex items-center gap-2">
|
|
216
|
+
{capabilities.vector && (
|
|
217
|
+
<Badge className="font-mono text-[10px]" variant="secondary">
|
|
218
|
+
vectors
|
|
219
|
+
</Badge>
|
|
220
|
+
)}
|
|
221
|
+
{capabilities.answer && (
|
|
222
|
+
<Badge className="font-mono text-[10px]" variant="secondary">
|
|
223
|
+
AI
|
|
224
|
+
</Badge>
|
|
225
|
+
)}
|
|
226
|
+
</div>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
</header>
|
|
230
|
+
|
|
231
|
+
{/* Conversation area */}
|
|
232
|
+
<main className="flex-1 overflow-y-auto p-8">
|
|
233
|
+
<div className="mx-auto max-w-3xl space-y-8">
|
|
234
|
+
{/* Empty state */}
|
|
235
|
+
{conversation.length === 0 && (
|
|
236
|
+
<div className="py-20 text-center">
|
|
237
|
+
<Sparkles className="mx-auto mb-4 size-12 text-primary/60" />
|
|
238
|
+
<h2 className="mb-2 font-medium text-lg">Ask anything</h2>
|
|
239
|
+
<p className="text-muted-foreground">
|
|
240
|
+
{answerAvailable
|
|
241
|
+
? 'Get AI-powered answers with citations from your documents'
|
|
242
|
+
: 'AI answers not available. Install a generation model to enable.'}
|
|
243
|
+
</p>
|
|
244
|
+
</div>
|
|
245
|
+
)}
|
|
246
|
+
|
|
247
|
+
{/* Conversation entries */}
|
|
248
|
+
{conversation.map((entry) => (
|
|
249
|
+
<div className="space-y-4" key={entry.id}>
|
|
250
|
+
{/* User query */}
|
|
251
|
+
<div className="flex justify-end">
|
|
252
|
+
<div className="max-w-[80%] rounded-lg bg-secondary px-4 py-3">
|
|
253
|
+
<p className="text-foreground">{entry.query}</p>
|
|
254
|
+
</div>
|
|
255
|
+
</div>
|
|
256
|
+
|
|
257
|
+
{/* AI response */}
|
|
258
|
+
<div className="space-y-3">
|
|
259
|
+
{entry.loading && (
|
|
260
|
+
<div className="flex items-center gap-3">
|
|
261
|
+
<Loader className="text-primary" size={20} />
|
|
262
|
+
<span className="text-muted-foreground text-sm">
|
|
263
|
+
Thinking...
|
|
264
|
+
</span>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{entry.error && (
|
|
269
|
+
<Card className="border-destructive bg-destructive/10">
|
|
270
|
+
<CardContent className="py-4 text-destructive">
|
|
271
|
+
{entry.error}
|
|
272
|
+
</CardContent>
|
|
273
|
+
</Card>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{entry.response && (
|
|
277
|
+
<>
|
|
278
|
+
{/* Answer */}
|
|
279
|
+
{entry.response.answer && (
|
|
280
|
+
<div className="prose prose-sm prose-invert max-w-none rounded-lg bg-card/50 p-4">
|
|
281
|
+
<p className="whitespace-pre-wrap leading-relaxed">
|
|
282
|
+
{renderAnswer(
|
|
283
|
+
entry.response.answer,
|
|
284
|
+
entry.response.citations ?? [],
|
|
285
|
+
navigate
|
|
286
|
+
)}
|
|
287
|
+
</p>
|
|
288
|
+
</div>
|
|
289
|
+
)}
|
|
290
|
+
|
|
291
|
+
{/* Citations */}
|
|
292
|
+
{entry.response.citations &&
|
|
293
|
+
entry.response.citations.length > 0 && (
|
|
294
|
+
<Sources defaultOpen>
|
|
295
|
+
<SourcesTrigger
|
|
296
|
+
count={entry.response.citations.length}
|
|
297
|
+
/>
|
|
298
|
+
<SourcesContent>
|
|
299
|
+
{entry.response.citations.map((c, i) => (
|
|
300
|
+
<Source
|
|
301
|
+
href="#"
|
|
302
|
+
key={`${c.docid}-${i}`}
|
|
303
|
+
onClick={(e) => {
|
|
304
|
+
e.preventDefault();
|
|
305
|
+
navigate(
|
|
306
|
+
`/doc?uri=${encodeURIComponent(c.uri)}`
|
|
307
|
+
);
|
|
308
|
+
}}
|
|
309
|
+
title={`[${i + 1}] ${c.uri.split('/').pop()}`}
|
|
310
|
+
>
|
|
311
|
+
<BookOpen className="size-4 shrink-0" />
|
|
312
|
+
<span className="truncate">
|
|
313
|
+
[{i + 1}] {c.uri.split('/').pop()}
|
|
314
|
+
</span>
|
|
315
|
+
{c.startLine && (
|
|
316
|
+
<span className="ml-1 font-mono text-[10px] text-muted-foreground/60">
|
|
317
|
+
L{c.startLine}
|
|
318
|
+
{c.endLine && `-${c.endLine}`}
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
</Source>
|
|
322
|
+
))}
|
|
323
|
+
</SourcesContent>
|
|
324
|
+
</Sources>
|
|
325
|
+
)}
|
|
326
|
+
|
|
327
|
+
{/* Meta info */}
|
|
328
|
+
<div className="flex items-center gap-2 text-muted-foreground/60 text-xs">
|
|
329
|
+
<span>{entry.response.results.length} results</span>
|
|
330
|
+
{entry.response.meta.vectorsUsed && (
|
|
331
|
+
<Badge
|
|
332
|
+
className="font-mono text-[9px]"
|
|
333
|
+
variant="outline"
|
|
334
|
+
>
|
|
335
|
+
hybrid
|
|
336
|
+
</Badge>
|
|
337
|
+
)}
|
|
338
|
+
{entry.response.meta.expanded && (
|
|
339
|
+
<Badge
|
|
340
|
+
className="font-mono text-[9px]"
|
|
341
|
+
variant="outline"
|
|
342
|
+
>
|
|
343
|
+
expanded
|
|
344
|
+
</Badge>
|
|
345
|
+
)}
|
|
346
|
+
</div>
|
|
347
|
+
|
|
348
|
+
{/* Show retrieved sources if no answer */}
|
|
349
|
+
{!entry.response.answer &&
|
|
350
|
+
entry.response.results.length > 0 && (
|
|
351
|
+
<div className="space-y-2">
|
|
352
|
+
<p className="font-medium text-muted-foreground text-sm">
|
|
353
|
+
Search results:
|
|
354
|
+
</p>
|
|
355
|
+
{entry.response.results.map((r, i) => (
|
|
356
|
+
<Card
|
|
357
|
+
className="cursor-pointer transition-colors hover:border-primary/50"
|
|
358
|
+
key={`${r.docid}-${i}`}
|
|
359
|
+
onClick={() =>
|
|
360
|
+
navigate(
|
|
361
|
+
`/doc?uri=${encodeURIComponent(r.uri)}`
|
|
362
|
+
)
|
|
363
|
+
}
|
|
364
|
+
>
|
|
365
|
+
<CardContent className="py-3">
|
|
366
|
+
<div className="flex items-start justify-between gap-2">
|
|
367
|
+
<div className="min-w-0">
|
|
368
|
+
<p className="font-medium text-primary text-sm">
|
|
369
|
+
{r.title || r.uri.split('/').pop()}
|
|
370
|
+
</p>
|
|
371
|
+
<p className="line-clamp-2 text-muted-foreground text-xs">
|
|
372
|
+
{r.snippet.slice(0, 200)}...
|
|
373
|
+
</p>
|
|
374
|
+
</div>
|
|
375
|
+
<Badge
|
|
376
|
+
className="shrink-0 font-mono text-[10px]"
|
|
377
|
+
variant="secondary"
|
|
378
|
+
>
|
|
379
|
+
{(r.score * 100).toFixed(0)}%
|
|
380
|
+
</Badge>
|
|
381
|
+
</div>
|
|
382
|
+
</CardContent>
|
|
383
|
+
</Card>
|
|
384
|
+
))}
|
|
385
|
+
</div>
|
|
386
|
+
)}
|
|
387
|
+
|
|
388
|
+
{/* No results */}
|
|
389
|
+
{!entry.response.answer &&
|
|
390
|
+
entry.response.results.length === 0 && (
|
|
391
|
+
<div className="flex items-center gap-2 text-muted-foreground">
|
|
392
|
+
<FileText className="size-4" />
|
|
393
|
+
<span className="text-sm">
|
|
394
|
+
No relevant results found
|
|
395
|
+
</span>
|
|
396
|
+
</div>
|
|
397
|
+
)}
|
|
398
|
+
</>
|
|
399
|
+
)}
|
|
400
|
+
</div>
|
|
401
|
+
</div>
|
|
402
|
+
))}
|
|
403
|
+
|
|
404
|
+
<div ref={messagesEndRef} />
|
|
405
|
+
</div>
|
|
406
|
+
</main>
|
|
407
|
+
|
|
408
|
+
{/* Input area */}
|
|
409
|
+
<footer className="glass sticky bottom-0 border-border/50 border-t">
|
|
410
|
+
<form
|
|
411
|
+
className="mx-auto flex max-w-3xl items-end gap-3 p-4"
|
|
412
|
+
onSubmit={handleSubmit}
|
|
413
|
+
>
|
|
414
|
+
<div className="relative flex-1">
|
|
415
|
+
<Textarea
|
|
416
|
+
className="min-h-[60px] resize-none pr-12"
|
|
417
|
+
disabled={!answerAvailable}
|
|
418
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
419
|
+
onKeyDown={handleKeyDown}
|
|
420
|
+
placeholder={
|
|
421
|
+
answerAvailable
|
|
422
|
+
? 'Ask a question about your documents...'
|
|
423
|
+
: 'AI answers not available'
|
|
424
|
+
}
|
|
425
|
+
ref={textareaRef}
|
|
426
|
+
rows={1}
|
|
427
|
+
value={query}
|
|
428
|
+
/>
|
|
429
|
+
<Button
|
|
430
|
+
className="absolute right-2 bottom-2"
|
|
431
|
+
disabled={!(query.trim() && answerAvailable)}
|
|
432
|
+
size="icon-sm"
|
|
433
|
+
type="submit"
|
|
434
|
+
>
|
|
435
|
+
<CornerDownLeft className="size-4" />
|
|
436
|
+
</Button>
|
|
437
|
+
</div>
|
|
438
|
+
</form>
|
|
439
|
+
</footer>
|
|
440
|
+
</div>
|
|
441
|
+
);
|
|
442
|
+
}
|