Skip to content

Commit c953f67

Browse files
committed
feat: full basic rag working
1 parent 155049b commit c953f67

File tree

24 files changed

+1386
-170
lines changed

24 files changed

+1386
-170
lines changed

app/components/Chat/Bubble/ChatBubble.vue

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,16 @@ function resizeTextarea() {
191191
:model="message.model"
192192
:usage="message.usage"
193193
/>
194+
<ChatBubbleRetrievedKnowledge
195+
v-if="
196+
message.role === 'assistant' &&
197+
message.knowledge &&
198+
message.retrievedKnowledge &&
199+
message.retrievedKnowledge.length > 0
200+
"
201+
:knowledge="message.knowledge"
202+
:retrieved-knowledge="message.retrievedKnowledge"
203+
/>
194204
<div
195205
class="flex gap-2 text-(--main-color)"
196206
:class="isButtonRowVisible ? 'opacity-100' : 'opacity-0'"
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import type { KnowledgeDocumentResponse } from "~~/server/utils/db/rag";
3+
4+
defineProps<{
5+
knowledge: KnowledgeState;
6+
retrievedKnowledge: KnowledgeDocumentResponse[];
7+
}>();
8+
const isModalOpen = ref(false);
9+
</script>
10+
11+
<template>
12+
<div class="flex items-center">
13+
<Icon
14+
name="lucide:library"
15+
class="cursor-pointer text-(--main-color)"
16+
@click="isModalOpen = true"
17+
/>
18+
</div>
19+
20+
<!-- popup -->
21+
<ModalWindow
22+
:open="isModalOpen"
23+
:shift-for-chat-list="true"
24+
@close="isModalOpen = false"
25+
>
26+
<div class="flex flex-col gap-2 max-h-[80vh]">
27+
<div class="flex items-center justify-between">
28+
<div class="flex items-center gap-2">
29+
<Icon name="lucide:library" class="text-(--main-color) scale-125" />
30+
<div class="font-bold">{{ knowledge.name }}</div>
31+
</div>
32+
<Icon
33+
name="lucide:x"
34+
class="text-(--main-color) cursor-pointer"
35+
@click="isModalOpen = false"
36+
/>
37+
</div>
38+
<div class="h-[1px] bg-(--sub-color) header-lines" />
39+
<div class="flex flex-col gap-2 overflow-y-auto">
40+
<div
41+
v-for="doc in retrievedKnowledge"
42+
:key="doc.id"
43+
class="whitespace-pre-wrap"
44+
>
45+
<div class="font-bold text-(--main-color) font-mono">
46+
{{ doc.source }} - {{ doc.chunkIndex }}
47+
</div>
48+
<div class="text-sm">
49+
{{ doc.text }}
50+
</div>
51+
<div class="text-(--sub-color) text-xs font-mono">
52+
{{ `distance: ${doc._distance}` }}
53+
</div>
54+
</div>
55+
</div>
56+
</div>
57+
</ModalWindow>
58+
</template>

app/components/Chat/ChatInput.vue

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ const { width } = useWindowSize();
4949
// Handle file uploads
5050
const uploadedFiles = ref<MessageFile[]>([]);
5151
const globalSettingsStore = useGlobalSettingsStore();
52+
53+
// Knowledge
54+
const knowledge = computed(() => {
55+
return useKnowledgeStore().knowledge;
56+
});
5257
</script>
5358

5459
<template>
@@ -126,7 +131,9 @@ const globalSettingsStore = useGlobalSettingsStore();
126131
}
127132
"
128133
/>
129-
<ChatInputKnowledge />
134+
<ChatInputKnowledge
135+
v-if="knowledge && Object.keys(knowledge).length"
136+
/>
130137
</div>
131138
</div>
132139
<div class="flex items-center chat-input-right">

app/pages/knowledge/[...id].vue

Lines changed: 244 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,144 @@ const routeId = computed(() => {
66
return Array.isArray(route.params.id) ? route.params.id[0] : route.params.id;
77
});
88
9-
const { knowledge, deleteKnowledge } = useKnowledgeStore();
9+
const { isLoading, knowledge, deleteKnowledge } = useKnowledgeStore();
1010
const k = computed(() => {
1111
if (!routeId.value) return null;
12-
if (!knowledge[routeId.value]) navigateTo("/knowledge");
12+
if (!knowledge[routeId.value] && !isLoading) navigateTo("/knowledge");
13+
// getDocuments(knowledge[routeId.value]?.name);
1314
return knowledge[routeId.value];
1415
});
1516
17+
// async function getDocuments(knowledgeName?: string) {
18+
// if (!knowledgeName) return;
19+
// const response = await $fetch("/api/knowledge/document", {
20+
// method: "GET",
21+
// params: {
22+
// knowledgeName,
23+
// type: "all",
24+
// },
25+
// });
26+
// if (!response) {
27+
// throw new Error("Failed to retrieve knowledge");
28+
// }
29+
// console.log("response:", response);
30+
// }
31+
1632
const deleteKnowledgeModalVisible = ref(false);
1733
const deleteKnowledgeConfirmation = ref("");
1834
const deleteKnowledgeConfirmationRef = ref<HTMLElement | null>(null);
35+
const showAddDocuments = ref(false);
36+
const fileInputRef = ref<HTMLInputElement | null>(null);
37+
const dropZoneRef = ref<HTMLDivElement | null>(null);
38+
const pendingFiles = ref<File[]>([]);
39+
const uploadProgress = ref<{
40+
fileName: string;
41+
percent: number;
42+
} | null>(null);
43+
const uploadStatus = ref(""); // Add a ref to display upload status
44+
45+
function onDrop(files: File[] | null) {
46+
if (files && files.length > 0) {
47+
// Filter for accepted types if needed, or rely on backend validation
48+
pendingFiles.value.push(...files);
49+
}
50+
}
51+
const { isOverDropZone } = useDropZone(dropZoneRef, {
52+
onDrop,
53+
// You might want to allow more types like '.pdf', etc.
54+
multiple: true,
55+
});
56+
57+
const triggerFileInput = () => {
58+
if (fileInputRef.value) {
59+
fileInputRef.value.click();
60+
}
61+
};
62+
63+
const handleFileChange = (event: Event) => {
64+
const fileInput = event.target as HTMLInputElement;
65+
if (fileInput.files && fileInput.files.length > 0) {
66+
const files = Array.from(fileInput.files);
67+
// Filter for accepted types if needed
68+
pendingFiles.value.push(...files);
69+
}
70+
};
71+
72+
async function uploadFiles() {
73+
if (!k.value) return;
74+
uploadStatus.value = "Uploading files..."; // Set status to uploading
75+
76+
if (pendingFiles.value.length === 0) {
77+
uploadStatus.value = "No files selected to upload.";
78+
console.warn("No files to upload.");
79+
return; // Stop the function if no files are present
80+
}
81+
82+
const formData = new FormData();
83+
formData.append("provider", k.value.provider);
84+
formData.append("dbName", k.value.name);
85+
86+
// Loop through the array of files and append each one
87+
// using the same key name ("documents").
88+
for (const file of pendingFiles.value) {
89+
formData.append("documents", file);
90+
}
91+
92+
// Handle the file upload
93+
try {
94+
// $fetch will correctly handle the FormData body for a POST request
95+
const response = await fetch("/api/knowledge/document", {
96+
method: "POST",
97+
body: formData,
98+
});
99+
100+
if (!response.ok || !response.body) {
101+
throw new Error(`Failed to create DB: ${response.statusText}`);
102+
}
103+
104+
const reader = response.body.getReader();
105+
const decoder = new TextDecoder();
106+
pendingFiles.value = []; // Clear the pending files after starting the upload
107+
108+
while (true) {
109+
const { done, value } = await reader.read();
110+
if (done) break;
111+
112+
// Decode the chunk and update the upload status
113+
const chunk = decoder.decode(value);
114+
const events = parseSSEChunk(chunk);
115+
for (const event of events) {
116+
if (event.eventType === "progress") {
117+
uploadProgress.value = JSON.parse(event.data);
118+
} else if (event.eventType === "error") {
119+
uploadStatus.value = `Error: ${event.data}`;
120+
} else if (event.eventType === "success") {
121+
const data = JSON.parse(event.data);
122+
const { updateKnowledge, fetchKnowledge } = useKnowledgeStore();
123+
updateKnowledge(
124+
data.id,
125+
data.dbName,
126+
data.provider,
127+
data.createdAt,
128+
data.updatedAt,
129+
data.documents,
130+
data.chunks,
131+
);
132+
await fetchKnowledge();
133+
showAddDocuments.value = false;
134+
}
135+
}
136+
}
137+
} catch (error) {
138+
console.error("Error uploading files:", error);
139+
uploadStatus.value = `Error uploading files: ${error}`; // Display error message
140+
} finally {
141+
// Clear the file input value regardless of success or failure
142+
if (fileInputRef.value) {
143+
fileInputRef.value.value = "";
144+
}
145+
}
146+
}
19147
</script>
20148
<template>
21149
<div class="w-full h-full flex flex-col overflow-hidden">
@@ -46,22 +174,118 @@ const deleteKnowledgeConfirmationRef = ref<HTMLElement | null>(null);
46174
{{ k?.updatedAt && new Date(k?.updatedAt).toLocaleDateString() }}
47175
{{ k?.updatedAt && new Date(k?.updatedAt).toLocaleTimeString() }}
48176
</div>
49-
<div>documents: {{ k?.details.documents }}</div>
50-
<div>chunks: {{ k?.details.chunks }}</div>
51-
<button
52-
class="bg-(--error-color) rounded-lg"
53-
@click="
54-
() => {
55-
deleteKnowledgeModalVisible = true;
56-
nextTick(() => {
57-
deleteKnowledgeConfirmationRef?.focus();
58-
});
59-
}
60-
"
61-
>
62-
<Icon name="lucide:trash-2" class="scale-125" />
63-
delete
64-
</button>
177+
<div>documents: {{ k?.documents }}</div>
178+
<div>chunks: {{ k?.chunks }}</div>
179+
<div class="flex gap-2">
180+
<button
181+
class="bg-(--sub-color) rounded-lg"
182+
@click="showAddDocuments = !showAddDocuments"
183+
>
184+
<Icon name="lucide:file-plus" class="scale-125" />
185+
add documents
186+
</button>
187+
<button
188+
class="bg-(--error-color) rounded-lg"
189+
@click="
190+
() => {
191+
deleteKnowledgeModalVisible = true;
192+
nextTick(() => {
193+
deleteKnowledgeConfirmationRef?.focus();
194+
});
195+
}
196+
"
197+
>
198+
<Icon name="lucide:trash-2" class="scale-125" />
199+
delete
200+
</button>
201+
</div>
202+
<div v-if="showAddDocuments">
203+
<div class="h-[1px] bg-(--sub-color) my-4" />
204+
<div class="flex flex-col gap-4">
205+
<h4>1. add files</h4>
206+
<div
207+
class="grid grid-cols-[1fr_min-content_2fr] items-center gap-4 h-[200px]"
208+
>
209+
<div
210+
class="h-full flex items-center justify-center gap-3 bg-(--sub-color) rounded-lg cursor-pointer"
211+
@click="triggerFileInput"
212+
@keydown.enter="triggerFileInput"
213+
@keydown.space="triggerFileInput"
214+
>
215+
<Icon
216+
name="lucide:upload"
217+
class="text-(--main-color) scale-150"
218+
/>
219+
browse local files
220+
</div>
221+
<input
222+
ref="fileInputRef"
223+
type="file"
224+
multiple
225+
accept=".txt,.pdf"
226+
class="hidden"
227+
@change="handleFileChange"
228+
/>
229+
<div>or</div>
230+
<div
231+
ref="dropZoneRef"
232+
class="h-full flex items-center justify-center bg-(--sub-alt-color) rounded-lg border-3 border-dashed"
233+
:class="
234+
isOverDropZone
235+
? 'border-(--main-color)'
236+
: 'border-(--sub-color)'
237+
"
238+
>
239+
drag and drop files here
240+
</div>
241+
</div>
242+
</div>
243+
<div
244+
v-if="pendingFiles.length"
245+
class="h-[1px] bg-(--sub-color) my-4"
246+
/>
247+
<div v-if="pendingFiles.length" class="flex flex-col gap-4">
248+
<h4>2. review files</h4>
249+
<div
250+
v-for="file in pendingFiles"
251+
:key="file.name"
252+
class="flex justify-between"
253+
>
254+
{{ file.name }}
255+
<Icon
256+
name="lucide:x"
257+
class="text-(--error-color) scale-150 cursor-pointer"
258+
@click="
259+
() => {
260+
pendingFiles = pendingFiles.filter(
261+
(f) => f.name !== file.name,
262+
);
263+
}
264+
"
265+
/>
266+
</div>
267+
<button
268+
class="bg-(--main-color) text-(--bg-color) rounded-lg p-2"
269+
@click="uploadFiles"
270+
>
271+
3. upload files and create database
272+
</button>
273+
</div>
274+
<!-- Display the upload status -->
275+
<div v-if="uploadProgress" class="flex gap-3 items-center">
276+
<div>{{ uploadProgress.fileName }}</div>
277+
<div class="w-[200px] h-2 bg-(--sub-color) rounded-full">
278+
<div
279+
class="h-full bg-(--main-color) rounded-full"
280+
:style="{
281+
width: (uploadProgress.percent || 0) + '%',
282+
}"
283+
/>
284+
</div>
285+
<div>{{ uploadProgress.percent || 0 }}%</div>
286+
</div>
287+
<div v-if="uploadStatus" class="mt-2 text-sm">{{ uploadStatus }}</div>
288+
</div>
65289
</div>
66290
</div>
67291

@@ -92,7 +316,7 @@ const deleteKnowledgeConfirmationRef = ref<HTMLElement | null>(null);
92316
@keyup.enter="
93317
async () => {
94318
if (!k?.id) return;
95-
await deleteKnowledge(k?.id);
319+
await deleteKnowledge(k?.id, k?.name);
96320
navigateTo('/knowledge');
97321
}
98322
"
@@ -108,7 +332,7 @@ const deleteKnowledgeConfirmationRef = ref<HTMLElement | null>(null);
108332
@click="
109333
async () => {
110334
if (!k?.id) return;
111-
await deleteKnowledge(k?.id);
335+
await deleteKnowledge(k?.id, k?.name);
112336
navigateTo('/knowledge');
113337
}
114338
"

0 commit comments

Comments
 (0)