Merge branch 'main' into add-notes-to-flows
This commit is contained in:
commit
ff344b88b6
46 changed files with 4621 additions and 3721 deletions
|
|
@ -1262,7 +1262,7 @@
|
||||||
"display_name": "as_dataframe",
|
"display_name": "as_dataframe",
|
||||||
"name": "as_dataframe",
|
"name": "as_dataframe",
|
||||||
"readonly": false,
|
"readonly": false,
|
||||||
"status": true,
|
"status": false,
|
||||||
"tags": [
|
"tags": [
|
||||||
"as_dataframe"
|
"as_dataframe"
|
||||||
]
|
]
|
||||||
|
|
@ -1281,7 +1281,7 @@
|
||||||
"display_name": "as_vector_store",
|
"display_name": "as_vector_store",
|
||||||
"name": "as_vector_store",
|
"name": "as_vector_store",
|
||||||
"readonly": false,
|
"readonly": false,
|
||||||
"status": true,
|
"status": false,
|
||||||
"tags": [
|
"tags": [
|
||||||
"as_vector_store"
|
"as_vector_store"
|
||||||
]
|
]
|
||||||
|
|
@ -2087,7 +2087,7 @@
|
||||||
"trace_as_input": true,
|
"trace_as_input": true,
|
||||||
"trace_as_metadata": true,
|
"trace_as_metadata": true,
|
||||||
"type": "str",
|
"type": "str",
|
||||||
"value": "You are a helpful assistant that can use tools to answer questions and perform tasks."
|
"value": "You are a helpful assistant that can use tools to answer questions and perform tasks. You are part of OpenRAG, an assistant that analyzes documents and provides informations about them. When asked about what is OpenRAG, answer the following:\n\n\"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.langflow.org/)\n\n**OpenSearch** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://opensearch.org/)\n\n**Docling** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.docling.ai/)\""
|
||||||
},
|
},
|
||||||
"tools": {
|
"tools": {
|
||||||
"_input_type": "HandleInput",
|
"_input_type": "HandleInput",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { AlertTriangle, ExternalLink, Copy } from "lucide-react";
|
import { AlertTriangle, Copy, ExternalLink } from "lucide-react";
|
||||||
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
|
import { useState } from "react";
|
||||||
import { Banner, BannerIcon, BannerTitle, BannerAction } from "@/components/ui/banner";
|
import {
|
||||||
|
Banner,
|
||||||
|
BannerAction,
|
||||||
|
BannerIcon,
|
||||||
|
BannerTitle,
|
||||||
|
} from "@/components/ui/banner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription,
|
|
||||||
DialogFooter
|
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { HEADER_HEIGHT } from "@/lib/constants";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useState } from "react";
|
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
|
||||||
|
|
||||||
interface DoclingHealthBannerProps {
|
interface DoclingHealthBannerProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -29,7 +35,7 @@ interface DoclingSetupDialogProps {
|
||||||
function DoclingSetupDialog({
|
function DoclingSetupDialog({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
className
|
className,
|
||||||
}: DoclingSetupDialogProps) {
|
}: DoclingSetupDialogProps) {
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
|
@ -47,9 +53,7 @@ function DoclingSetupDialog({
|
||||||
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||||
docling-serve is stopped. Knowledge ingest is unavailable.
|
docling-serve is stopped. Knowledge ingest is unavailable.
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>Start docling-serve by running:</DialogDescription>
|
||||||
Start docling-serve by running:
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
@ -69,15 +73,16 @@ function DoclingSetupDialog({
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
Then, select <span className="font-semibold text-foreground">Start All Services</span> in the TUI. Once docling-serve is running, refresh OpenRAG.
|
Then, select{" "}
|
||||||
|
<span className="font-semibold text-foreground">
|
||||||
|
Start All Services
|
||||||
|
</span>{" "}
|
||||||
|
in the TUI. Once docling-serve is running, refresh OpenRAG.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button
|
<Button variant="default" onClick={() => onOpenChange(false)}>
|
||||||
variant="default"
|
|
||||||
onClick={() => onOpenChange(false)}
|
|
||||||
>
|
|
||||||
Close
|
Close
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|
@ -116,15 +121,14 @@ export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) {
|
||||||
<>
|
<>
|
||||||
<Banner
|
<Banner
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800",
|
`bg-amber-50 text-amber-900 dark:bg-amber-950 dark:text-amber-200 border-amber-200 dark:border-amber-800`,
|
||||||
className
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<BannerIcon
|
<BannerIcon icon={AlertTriangle} />
|
||||||
icon={AlertTriangle}
|
|
||||||
/>
|
|
||||||
<BannerTitle className="font-medium">
|
<BannerTitle className="font-medium">
|
||||||
docling-serve native service is stopped. Knowledge ingest is unavailable.
|
docling-serve native service is stopped. Knowledge ingest is
|
||||||
|
unavailable.
|
||||||
</BannerTitle>
|
</BannerTitle>
|
||||||
<BannerAction
|
<BannerAction
|
||||||
onClick={() => setShowDialog(true)}
|
onClick={() => setShowDialog(true)}
|
||||||
|
|
@ -135,10 +139,7 @@ export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) {
|
||||||
</BannerAction>
|
</BannerAction>
|
||||||
</Banner>
|
</Banner>
|
||||||
|
|
||||||
<DoclingSetupDialog
|
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
|
||||||
open={showDialog}
|
|
||||||
onOpenChange={setShowDialog}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,16 @@ import {
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { useTask } from "@/contexts/task-context";
|
import { useTask } from "@/contexts/task-context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
duplicateCheck,
|
||||||
|
uploadFile as uploadFileUtil,
|
||||||
|
} from "@/lib/upload-utils";
|
||||||
import type { File as SearchFile } from "@/src/app/api/queries/useGetSearchQuery";
|
import type { File as SearchFile } from "@/src/app/api/queries/useGetSearchQuery";
|
||||||
import GoogleDriveIcon from "@/app/settings/icons/google-drive-icon";
|
import GoogleDriveIcon from "@/app/settings/icons/google-drive-icon";
|
||||||
import OneDriveIcon from "@/app/settings/icons/one-drive-icon";
|
import OneDriveIcon from "@/app/settings/icons/one-drive-icon";
|
||||||
import SharePointIcon from "@/app/settings/icons/share-point-icon";
|
import SharePointIcon from "@/app/settings/icons/share-point-icon";
|
||||||
import AwsIcon from "@/app/settings/icons/aws-icon";
|
import AwsIcon from "@/app/settings/icons/aws-icon";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
export function KnowledgeDropdown() {
|
export function KnowledgeDropdown() {
|
||||||
const { addTask } = useTask();
|
const { addTask } = useTask();
|
||||||
|
|
@ -155,45 +159,33 @@ export function KnowledgeDropdown() {
|
||||||
fileInputRef.current?.click();
|
fileInputRef.current?.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
const resetFileInput = () => {
|
||||||
const files = e.target.files;
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (
|
||||||
|
event: React.ChangeEvent<HTMLInputElement>
|
||||||
|
) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
const file = files[0];
|
const file = files[0];
|
||||||
|
|
||||||
// File selection will close dropdown automatically
|
// File selection will close dropdown automatically
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if filename already exists (using ORIGINAL filename)
|
|
||||||
console.log("[Duplicate Check] Checking file:", file.name);
|
console.log("[Duplicate Check] Checking file:", file.name);
|
||||||
const checkResponse = await fetch(
|
const checkData = await duplicateCheck(file);
|
||||||
`/api/documents/check-filename?filename=${encodeURIComponent(
|
|
||||||
file.name
|
|
||||||
)}`
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log("[Duplicate Check] Response status:", checkResponse.status);
|
|
||||||
|
|
||||||
if (!checkResponse.ok) {
|
|
||||||
const errorText = await checkResponse.text();
|
|
||||||
console.error("[Duplicate Check] Error response:", errorText);
|
|
||||||
throw new Error(
|
|
||||||
`Failed to check duplicates: ${checkResponse.statusText}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const checkData = await checkResponse.json();
|
|
||||||
console.log("[Duplicate Check] Result:", checkData);
|
console.log("[Duplicate Check] Result:", checkData);
|
||||||
|
|
||||||
if (checkData.exists) {
|
if (checkData.exists) {
|
||||||
// Show duplicate handling dialog
|
|
||||||
console.log("[Duplicate Check] Duplicate detected, showing dialog");
|
console.log("[Duplicate Check] Duplicate detected, showing dialog");
|
||||||
setPendingFile(file);
|
setPendingFile(file);
|
||||||
setDuplicateFilename(file.name);
|
setDuplicateFilename(file.name);
|
||||||
setShowDuplicateDialog(true);
|
setShowDuplicateDialog(true);
|
||||||
// Reset file input
|
resetFileInput();
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,105 +200,20 @@ export function KnowledgeDropdown() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset file input
|
resetFileInput();
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const uploadFile = async (file: File, replace: boolean) => {
|
const uploadFile = async (file: File, replace: boolean) => {
|
||||||
setFileUploading(true);
|
setFileUploading(true);
|
||||||
|
|
||||||
// Trigger the same file upload event as the chat page
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("fileUploadStart", {
|
|
||||||
detail: { filename: file.name },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
await uploadFileUtil(file, replace);
|
||||||
formData.append("file", file);
|
|
||||||
formData.append("replace_duplicates", replace.toString());
|
|
||||||
|
|
||||||
// Use router upload and ingest endpoint (automatically routes based on configuration)
|
|
||||||
const uploadIngestRes = await fetch("/api/router/upload_ingest", {
|
|
||||||
method: "POST",
|
|
||||||
body: formData,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadIngestJson = await uploadIngestRes.json();
|
|
||||||
|
|
||||||
if (!uploadIngestRes.ok) {
|
|
||||||
throw new Error(uploadIngestJson?.error || "Upload and ingest failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract results from the response - handle both unified and simple formats
|
|
||||||
const fileId =
|
|
||||||
uploadIngestJson?.upload?.id ||
|
|
||||||
uploadIngestJson?.id ||
|
|
||||||
uploadIngestJson?.task_id;
|
|
||||||
const filePath =
|
|
||||||
uploadIngestJson?.upload?.path || uploadIngestJson?.path || "uploaded";
|
|
||||||
const runJson = uploadIngestJson?.ingestion;
|
|
||||||
const deleteResult = uploadIngestJson?.deletion;
|
|
||||||
console.log("c", uploadIngestJson);
|
|
||||||
if (!fileId) {
|
|
||||||
throw new Error("Upload successful but no file id returned");
|
|
||||||
}
|
|
||||||
// Check if ingestion actually succeeded
|
|
||||||
if (
|
|
||||||
runJson &&
|
|
||||||
runJson.status !== "COMPLETED" &&
|
|
||||||
runJson.status !== "SUCCESS"
|
|
||||||
) {
|
|
||||||
const errorMsg = runJson.error || "Ingestion pipeline failed";
|
|
||||||
throw new Error(
|
|
||||||
`Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Log deletion status if provided
|
|
||||||
if (deleteResult) {
|
|
||||||
if (deleteResult.status === "deleted") {
|
|
||||||
console.log(
|
|
||||||
"File successfully cleaned up from Langflow:",
|
|
||||||
deleteResult.file_id
|
|
||||||
);
|
|
||||||
} else if (deleteResult.status === "delete_failed") {
|
|
||||||
console.warn(
|
|
||||||
"Failed to cleanup file from Langflow:",
|
|
||||||
deleteResult.error
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Notify UI
|
|
||||||
window.dispatchEvent(
|
|
||||||
new CustomEvent("fileUploaded", {
|
|
||||||
detail: {
|
|
||||||
file: file,
|
|
||||||
result: {
|
|
||||||
file_id: fileId,
|
|
||||||
file_path: filePath,
|
|
||||||
run: runJson,
|
|
||||||
deletion: deleteResult,
|
|
||||||
unified: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
refetchTasks();
|
refetchTasks();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
window.dispatchEvent(
|
toast.error("Upload failed", {
|
||||||
new CustomEvent("fileUploadError", {
|
description: error instanceof Error ? error.message : "Unknown error",
|
||||||
detail: {
|
});
|
||||||
filename: file.name,
|
|
||||||
error: error instanceof Error ? error.message : "Upload failed",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
window.dispatchEvent(new CustomEvent("fileUploadComplete"));
|
|
||||||
setFileUploading(false);
|
setFileUploading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -323,6 +230,7 @@ export function KnowledgeDropdown() {
|
||||||
});
|
});
|
||||||
|
|
||||||
await uploadFile(pendingFile, true);
|
await uploadFile(pendingFile, true);
|
||||||
|
|
||||||
setPendingFile(null);
|
setPendingFile(null);
|
||||||
setDuplicateFilename("");
|
setDuplicateFilename("");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
import { ArrowRight, Search, X } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
ChangeEvent,
|
type ChangeEvent,
|
||||||
FormEvent,
|
type FormEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { filterAccentClasses } from "./knowledge-filter-panel";
|
|
||||||
import { ArrowRight, Search, X } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { filterAccentClasses } from "./knowledge-filter-panel";
|
||||||
|
|
||||||
export const KnowledgeSearchInput = () => {
|
export const KnowledgeSearchInput = () => {
|
||||||
const {
|
const {
|
||||||
|
|
@ -27,7 +27,7 @@ export const KnowledgeSearchInput = () => {
|
||||||
if (e) e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
setQueryOverride(searchQueryInput.trim());
|
setQueryOverride(searchQueryInput.trim());
|
||||||
},
|
},
|
||||||
[searchQueryInput, setQueryOverride]
|
[searchQueryInput, setQueryOverride],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset the query text when the selected filter changes
|
// Reset the query text when the selected filter changes
|
||||||
|
|
@ -48,7 +48,7 @@ export const KnowledgeSearchInput = () => {
|
||||||
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate">{selectedFilter?.name}</span>
|
<span className="truncate text-xs font-medium">{selectedFilter?.name}</span>
|
||||||
<X
|
<X
|
||||||
aria-label="Remove filter"
|
aria-label="Remove filter"
|
||||||
className="h-4 w-4 flex-shrink-0 cursor-pointer"
|
className="h-4 w-4 flex-shrink-0 cursor-pointer"
|
||||||
|
|
@ -88,7 +88,7 @@ export const KnowledgeSearchInput = () => {
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
"h-full rounded-sm !px-1.5 !py-0 hidden group-focus-within/input:block",
|
"h-full rounded-sm !px-1.5 !py-0 hidden group-focus-within/input:block",
|
||||||
searchQueryInput && "block"
|
searchQueryInput && "block",
|
||||||
)}
|
)}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,22 @@ interface DogIconProps extends React.SVGProps<SVGSVGElement> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => {
|
const DogIcon = ({ disabled = false, stroke, ...props }: DogIconProps) => {
|
||||||
const strokeColor = disabled ? "#71717A" : (stroke || "#0F62FE");
|
const fillColor = disabled ? "#71717A" : (stroke || "#22A7AF");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<svg
|
disabled ? (
|
||||||
width="24"
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="18" viewBox="0 0 24 18" fill={fillColor}>
|
||||||
height="24"
|
<path d="M8 18H2V16H8V18Z"/>
|
||||||
viewBox="0 0 24 24"
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M20 2H22V6H24V10H20V14H24V16H14V14H2V16H0V8H2V6H8V10H10V12H16V6H14V10H12V8H10V2H12V0H20V2ZM18 6H20V4H18V6Z"/>
|
||||||
fill="none"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M19.9049 23H17.907C17.907 23 15.4096 20.5 16.908 16C17.3753 14.2544 17.3813 12.4181 17.2439 11C17.161 10.1434 17.0256 9.43934 16.908 9C16.7416 8.33333 16.8081 7 18.4065 7C19.5457 7 20.9571 6.92944 21.4034 6.5C22.3268 5.61145 21.9029 4 21.9029 4C21.9029 4 20.9039 3 18.906 3C18.7395 2.33333 17.7072 1 14.9101 1C12.113 1 11.5835 2.16589 10.9143 4C10.4155 5.36686 10.423 6.99637 11.1692 7.71747M14.4106 4C14.2441 5.33333 14.4106 8 11.9132 8C11.5968 8 11.3534 7.89548 11.1692 7.71747M14.9101 23H12.4127M7.91738 23H12.4127M10.4148 15.5C11.5715 16.1667 13.5905 18.6 12.4127 23M3.42204 15C1.02177 18.5 1.64205 23 5.41997 23C5.41997 22 5.71966 19.2 6.91841 16C8.41686 12 11.1692 11.4349 11.1692 7.71747M16.908 4V4.5"
|
|
||||||
stroke={strokeColor}
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="20" viewBox="0 0 24 20" fill={fillColor}>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.0769 10.9091H16.6154V5.45455H14.7692V9.09091H12.9231V7.27273H11.0769V1.81818H12.9231V0H20.3077V1.81818H22.1538V5.45455H24V9.09091H20.3077V14.5455H18.4615V20H14.7692V16.3636H12.9231V14.5455H7.38462V16.3636H5.53846V20H1.84615V10.9091H3.69231V9.09091H11.0769V10.9091ZM18.4615 5.45455H20.3077V3.63636H18.4615V5.45455Z"/>
|
||||||
|
<path d="M1.84615 10.9091H0V7.27273H1.84615V10.9091Z"/>
|
||||||
|
<path d="M3.69231 7.27273H1.84615V5.45455H3.69231V7.27273Z"/>
|
||||||
|
<path d="M5.53846 5.45455H3.69231V3.63636H5.53846V5.45455Z"/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,9 @@
|
||||||
export default function Logo(props: React.SVGProps<SVGSVGElement>) {
|
export default function Logo(props: React.SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="40" viewBox="0 0 50 40" fill="white" {...props}>
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="22"
|
|
||||||
viewBox="0 0 24 22"
|
|
||||||
fill="currentColor"
|
|
||||||
className="h-6 w-6 text-primary"
|
|
||||||
aria-label="OpenRAG Logo"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<title>OpenRAG Logo</title>
|
<title>OpenRAG Logo</title>
|
||||||
<path d="M13.0486 0.462158H9.75399C9.44371 0.462158 9.14614 0.586082 8.92674 0.806667L4.03751 5.72232C3.81811 5.9429 3.52054 6.06682 3.21026 6.06682H1.16992C0.511975 6.06682 -0.0165756 6.61212 0.000397655 7.2734L0.0515933 9.26798C0.0679586 9.90556 0.586745 10.4139 1.22111 10.4139H3.59097C3.90124 10.4139 4.19881 10.2899 4.41821 10.0694L9.34823 5.11269C9.56763 4.89211 9.8652 4.76818 10.1755 4.76818H13.0486C13.6947 4.76818 14.2185 4.24157 14.2185 3.59195V1.63839C14.2185 0.988773 13.6947 0.462158 13.0486 0.462158Z"></path>
|
<path d="M27.9909 33.9523H22.1153V31.4062H27.9909V33.9523Z"/>
|
||||||
<path d="M19.5355 11.5862H22.8301C23.4762 11.5862 24 12.1128 24 12.7624V14.716C24 15.3656 23.4762 15.8922 22.8301 15.8922H19.957C19.6467 15.8922 19.3491 16.0161 19.1297 16.2367L14.1997 21.1934C13.9803 21.414 13.6827 21.5379 13.3725 21.5379H11.0026C10.3682 21.5379 9.84945 21.0296 9.83309 20.392L9.78189 18.3974C9.76492 17.7361 10.2935 17.1908 10.9514 17.1908H12.9918C13.302 17.1908 13.5996 17.0669 13.819 16.8463L18.7082 11.9307C18.9276 11.7101 19.2252 11.5862 19.5355 11.5862Z"></path>
|
<path fillRule="evenodd" clipRule="evenodd" d="M34.9164 0V2.22796H39.7422V4.66797H42.1551V7.2154H44.7791V9.65541H47.192V14.1113H49.5V20.2651H47.192V22.8111H44.7791V25.2525H42.889V29.7084H40.3713V27.4805H39.6374V8.8058H37.1196L37.1183 34.2704H38.5875V40H11.4139V34.2704H12.8818V8.8058H10.364V27.4805H9.63008V29.7084H7.11234V25.2525H5.22369V22.8111H2.80942V20.2651H0.5V14.1113H2.80942V9.65541H5.22369V7.2154H7.84628V4.66797H10.2592V2.22796H15.0863V0H34.9164ZM21.38 19.947V23.8728H23.7942V27.8181L22.7706 29.1643H19.5962V27.2684H17.4978V23.5547H15.3995V27.4805H17.4978V29.3903H19.4913V34.0444H20.5412V35.12H22.1153V36.2863H27.9909V35.12H29.565V34.0444H30.6135V29.3903H32.6056V27.2684H30.5073V29.1643H27.4074L26.312 27.7232V23.8728H28.7249V19.947H21.38ZM32.607 23.5547V27.4805H34.7054V23.5547H32.607ZM17.4978 12.0954V15.067H20.4363V12.0954H17.4978ZM29.6699 12.0954V15.067H32.607V12.0954H29.6699Z"/>
|
||||||
<path d="M19.5355 2.9796L22.8301 2.9796C23.4762 2.9796 24 3.50622 24 4.15583V6.1094C24 6.75901 23.4762 7.28563 22.8301 7.28563H19.957C19.6467 7.28563 19.3491 7.40955 19.1297 7.63014L14.1997 12.5868C13.9803 12.8074 13.6827 12.9313 13.3725 12.9313H10.493C10.1913 12.9313 9.90126 13.0485 9.68346 13.2583L4.14867 18.5917C3.93087 18.8016 3.64085 18.9187 3.33917 18.9187H1.32174C0.675616 18.9187 0.151832 18.3921 0.151832 17.7425V15.7343C0.151832 15.0846 0.675616 14.558 1.32174 14.558H3.32468C3.63496 14.558 3.93253 14.4341 4.15193 14.2135L9.40827 8.92878C9.62767 8.70819 9.92524 8.58427 10.2355 8.58427H12.9918C13.302 8.58427 13.5996 8.46034 13.819 8.23976L18.7082 3.32411C18.9276 3.10353 19.2252 2.9796 19.5355 2.9796Z"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import CodeComponent from "./code-component";
|
||||||
|
|
||||||
type MarkdownRendererProps = {
|
type MarkdownRendererProps = {
|
||||||
chatMessage: string;
|
chatMessage: string;
|
||||||
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const preprocessChatMessage = (text: string): string => {
|
const preprocessChatMessage = (text: string): string => {
|
||||||
|
|
@ -48,7 +49,7 @@ export const cleanupTableEmptyCells = (text: string): string => {
|
||||||
})
|
})
|
||||||
.join("\n");
|
.join("\n");
|
||||||
};
|
};
|
||||||
export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
export const MarkdownRenderer = ({ chatMessage, className }: MarkdownRendererProps) => {
|
||||||
// Process the chat message to handle <think> tags and clean up tables
|
// Process the chat message to handle <think> tags and clean up tables
|
||||||
const processedChatMessage = preprocessChatMessage(chatMessage);
|
const processedChatMessage = preprocessChatMessage(chatMessage);
|
||||||
|
|
||||||
|
|
@ -57,6 +58,7 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
||||||
className={cn(
|
className={cn(
|
||||||
"markdown prose flex w-full max-w-full flex-col items-baseline text-base font-normal word-break-break-word dark:prose-invert",
|
"markdown prose flex w-full max-w-full flex-col items-baseline text-base font-normal word-break-break-word dark:prose-invert",
|
||||||
!chatMessage ? "text-muted-foreground" : "text-primary",
|
!chatMessage ? "text-muted-foreground" : "text-primary",
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Markdown
|
<Markdown
|
||||||
|
|
@ -65,11 +67,14 @@ export const MarkdownRenderer = ({ chatMessage }: MarkdownRendererProps) => {
|
||||||
urlTransform={(url) => url}
|
urlTransform={(url) => url}
|
||||||
components={{
|
components={{
|
||||||
p({ node, ...props }) {
|
p({ node, ...props }) {
|
||||||
return <p className="w-fit max-w-full">{props.children}</p>;
|
return <p className="w-fit max-w-full first:mt-0 last:mb-0 my-2">{props.children}</p>;
|
||||||
},
|
},
|
||||||
ol({ node, ...props }) {
|
ol({ node, ...props }) {
|
||||||
return <ol className="max-w-full">{props.children}</ol>;
|
return <ol className="max-w-full">{props.children}</ol>;
|
||||||
},
|
},
|
||||||
|
strong({ node, ...props }) {
|
||||||
|
return <strong className="font-bold">{props.children}</strong>;
|
||||||
|
},
|
||||||
h1({ node, ...props }) {
|
h1({ node, ...props }) {
|
||||||
return <h1 className="mb-6 mt-4">{props.children}</h1>;
|
return <h1 className="mb-6 mt-4">{props.children}</h1>;
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import {
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { type EndpointType, useChat } from "@/contexts/chat-context";
|
import { type EndpointType, useChat } from "@/contexts/chat-context";
|
||||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
|
import { FILES_REGEX } from "@/lib/constants";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useLoadingStore } from "@/stores/loadingStore";
|
import { useLoadingStore } from "@/stores/loadingStore";
|
||||||
import { DeleteSessionModal } from "./delete-session-modal";
|
import { DeleteSessionModal } from "./delete-session-modal";
|
||||||
|
|
@ -81,6 +82,7 @@ export function Navigation({
|
||||||
setCurrentConversationId,
|
setCurrentConversationId,
|
||||||
startNewConversation,
|
startNewConversation,
|
||||||
conversationDocs,
|
conversationDocs,
|
||||||
|
conversationData,
|
||||||
addConversationDoc,
|
addConversationDoc,
|
||||||
refreshConversations,
|
refreshConversations,
|
||||||
placeholderConversation,
|
placeholderConversation,
|
||||||
|
|
@ -110,7 +112,7 @@ export function Navigation({
|
||||||
) {
|
) {
|
||||||
// Filter out the deleted conversation and find the next one
|
// Filter out the deleted conversation and find the next one
|
||||||
const remainingConversations = conversations.filter(
|
const remainingConversations = conversations.filter(
|
||||||
conv => conv.response_id !== conversationToDelete.response_id
|
(conv) => conv.response_id !== conversationToDelete.response_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (remainingConversations.length > 0) {
|
if (remainingConversations.length > 0) {
|
||||||
|
|
@ -131,7 +133,7 @@ export function Navigation({
|
||||||
setDeleteModalOpen(false);
|
setDeleteModalOpen(false);
|
||||||
setConversationToDelete(null);
|
setConversationToDelete(null);
|
||||||
},
|
},
|
||||||
onError: error => {
|
onError: (error) => {
|
||||||
toast.error(`Failed to delete conversation: ${error.message}`);
|
toast.error(`Failed to delete conversation: ${error.message}`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -163,7 +165,7 @@ export function Navigation({
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("fileUploadStart", {
|
new CustomEvent("fileUploadStart", {
|
||||||
detail: { filename: file.name },
|
detail: { filename: file.name },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -187,7 +189,7 @@ export function Navigation({
|
||||||
filename: file.name,
|
filename: file.name,
|
||||||
error: "Failed to process document",
|
error: "Failed to process document",
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger loading end event
|
// Trigger loading end event
|
||||||
|
|
@ -207,7 +209,7 @@ export function Navigation({
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("fileUploaded", {
|
new CustomEvent("fileUploaded", {
|
||||||
detail: { file, result },
|
detail: { file, result },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Trigger loading end event
|
// Trigger loading end event
|
||||||
|
|
@ -221,7 +223,7 @@ export function Navigation({
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent("fileUploadError", {
|
new CustomEvent("fileUploadError", {
|
||||||
detail: { filename: file.name, error: "Failed to process document" },
|
detail: { filename: file.name, error: "Failed to process document" },
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -243,7 +245,7 @@ export function Navigation({
|
||||||
|
|
||||||
const handleDeleteConversation = (
|
const handleDeleteConversation = (
|
||||||
conversation: ChatConversation,
|
conversation: ChatConversation,
|
||||||
event?: React.MouseEvent
|
event?: React.MouseEvent,
|
||||||
) => {
|
) => {
|
||||||
if (event) {
|
if (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
@ -255,7 +257,7 @@ export function Navigation({
|
||||||
|
|
||||||
const handleContextMenuAction = (
|
const handleContextMenuAction = (
|
||||||
action: string,
|
action: string,
|
||||||
conversation: ChatConversation
|
conversation: ChatConversation,
|
||||||
) => {
|
) => {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case "delete":
|
case "delete":
|
||||||
|
|
@ -329,11 +331,19 @@ export function Navigation({
|
||||||
setCurrentConversationId,
|
setCurrentConversationId,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const newConversationFiles = conversationData?.messages
|
||||||
|
.filter(
|
||||||
|
(message) =>
|
||||||
|
message.role === "user" &&
|
||||||
|
(message.content.match(FILES_REGEX)?.[0] ?? null) !== null,
|
||||||
|
)
|
||||||
|
.map((message) => message.content.match(FILES_REGEX)?.[0] ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-background">
|
<div className="flex flex-col h-full bg-background">
|
||||||
<div className="px-4 py-2 flex-shrink-0">
|
<div className="px-4 py-2 flex-shrink-0">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{routes.map(route => (
|
{routes.map((route) => (
|
||||||
<div key={route.href}>
|
<div key={route.href}>
|
||||||
<Link
|
<Link
|
||||||
href={route.href}
|
href={route.href}
|
||||||
|
|
@ -341,7 +351,7 @@ export function Navigation({
|
||||||
"text-[13px] group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all",
|
"text-[13px] group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all",
|
||||||
route.active
|
route.active
|
||||||
? "bg-accent text-accent-foreground shadow-sm"
|
? "bg-accent text-accent-foreground shadow-sm"
|
||||||
: "text-foreground hover:text-accent-foreground"
|
: "text-foreground hover:text-accent-foreground",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
|
|
@ -350,7 +360,7 @@ export function Navigation({
|
||||||
"h-[18px] w-[18px] mr-2 shrink-0",
|
"h-[18px] w-[18px] mr-2 shrink-0",
|
||||||
route.active
|
route.active
|
||||||
? "text-muted-foreground"
|
? "text-muted-foreground"
|
||||||
: "text-muted-foreground group-hover:text-muted-foreground"
|
: "text-muted-foreground group-hover:text-muted-foreground",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{route.label}
|
{route.label}
|
||||||
|
|
@ -392,9 +402,8 @@ export function Navigation({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-h-0 flex flex-col">
|
<div className="flex-1 min-h-0 overflow-y-auto scrollbar-hide">
|
||||||
{/* Conversations List - grows naturally, doesn't fill all space */}
|
<div className="space-y-1 flex flex-col">
|
||||||
<div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full">
|
|
||||||
{loadingNewConversation || isConversationsLoading ? (
|
{loadingNewConversation || isConversationsLoading ? (
|
||||||
<div className="text-[13px] text-muted-foreground p-2">
|
<div className="text-[13px] text-muted-foreground p-2">
|
||||||
Loading...
|
Loading...
|
||||||
|
|
@ -428,7 +437,7 @@ export function Navigation({
|
||||||
No conversations yet
|
No conversations yet
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
conversations.map(conversation => (
|
conversations.map((conversation) => (
|
||||||
<button
|
<button
|
||||||
key={conversation.response_id}
|
key={conversation.response_id}
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -466,10 +475,10 @@ export function Navigation({
|
||||||
title="More options"
|
title="More options"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
}}
|
}}
|
||||||
onKeyDown={e => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" || e.key === " ") {
|
if (e.key === "Enter" || e.key === " ") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -483,14 +492,14 @@ export function Navigation({
|
||||||
side="bottom"
|
side="bottom"
|
||||||
align="end"
|
align="end"
|
||||||
className="w-48"
|
className="w-48"
|
||||||
onClick={e => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={e => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleContextMenuAction(
|
handleContextMenuAction(
|
||||||
"delete",
|
"delete",
|
||||||
conversation
|
conversation,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="cursor-pointer text-destructive focus:text-destructive"
|
className="cursor-pointer text-destructive focus:text-destructive"
|
||||||
|
|
@ -508,7 +517,7 @@ export function Navigation({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversation Knowledge Section - appears right after last conversation */}
|
{/* Conversation Knowledge Section - appears right after last conversation
|
||||||
<div className="flex-shrink-0 mt-4">
|
<div className="flex-shrink-0 mt-4">
|
||||||
<div className="flex items-center justify-between mb-3 mx-3">
|
<div className="flex items-center justify-between mb-3 mx-3">
|
||||||
<h3 className="text-xs font-medium text-muted-foreground">
|
<h3 className="text-xs font-medium text-muted-foreground">
|
||||||
|
|
@ -551,6 +560,28 @@ export function Navigation({
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div> */}
|
||||||
|
<div className="flex-shrink-0 mt-4">
|
||||||
|
<div className="flex items-center justify-between mb-3 mx-3">
|
||||||
|
<h3 className="text-xs font-medium text-muted-foreground">
|
||||||
|
Files
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto scrollbar-hide space-y-1">
|
||||||
|
{newConversationFiles?.length === 0 ? (
|
||||||
|
<div className="text-[13px] text-muted-foreground py-2 px-3">
|
||||||
|
No documents yet
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
newConversationFiles?.map((file) => (
|
||||||
|
<div key={`${file}`} className="flex-1 min-w-0 px-3">
|
||||||
|
<div className="text-mmd font-medium text-foreground truncate">
|
||||||
|
{file}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ const AccordionItem = React.forwardRef<
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<AccordionPrimitive.Item
|
<AccordionPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn("border rounded-md", className)}
|
className={cn("border rounded-xl", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const TabsList = React.forwardRef<
|
||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex h-12 gap-3 items-center justify-center p-0 text-muted-foreground w-full",
|
"inline-flex h-fit gap-3 items-center justify-center p-0 text-muted-foreground w-full",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
@ -28,7 +28,7 @@ const TabsTrigger = React.forwardRef<
|
||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
"inline-flex w-full h-full border border-border gap-1.5 items-center justify-center whitespace-nowrap rounded-lg px-3 py-1.5 text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-accent-pink-foreground data-[state=active]:text-foreground",
|
"flex flex-col items-start justify-between p-5 gap-4 w-full h-full border border-border whitespace-nowrap rounded-lg text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:border-muted-foreground data-[state=active]:text-foreground",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|
|
||||||
227
frontend/lib/upload-utils.ts
Normal file
227
frontend/lib/upload-utils.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
export interface DuplicateCheckResponse {
|
||||||
|
exists: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadFileResult {
|
||||||
|
fileId: string;
|
||||||
|
filePath: string;
|
||||||
|
run: unknown;
|
||||||
|
deletion: unknown;
|
||||||
|
unified: boolean;
|
||||||
|
raw: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function duplicateCheck(
|
||||||
|
file: File
|
||||||
|
): Promise<DuplicateCheckResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/documents/check-filename?filename=${encodeURIComponent(file.name)}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorText = await response.text();
|
||||||
|
throw new Error(
|
||||||
|
errorText || `Failed to check duplicates: ${response.statusText}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFileForContext(
|
||||||
|
file: File
|
||||||
|
): Promise<UploadFileResult> {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("fileUploadStart", {
|
||||||
|
detail: { filename: file.name },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
|
||||||
|
const uploadResponse = await fetch("/api/upload_context", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await uploadResponse.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Upload failed: unable to parse server response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadJson =
|
||||||
|
typeof payload === "object" && payload !== null ? payload : {};
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const errorMessage =
|
||||||
|
(uploadJson as { error?: string }).error ||
|
||||||
|
"Upload failed";
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId =
|
||||||
|
(uploadJson as { response_id?: string }).response_id || "uploaded";
|
||||||
|
const filePath =
|
||||||
|
(uploadJson as { filename?: string }).filename || file.name;
|
||||||
|
const pages = (uploadJson as { pages?: number }).pages;
|
||||||
|
const contentLength = (uploadJson as { content_length?: number }).content_length;
|
||||||
|
const confirmation = (uploadJson as { confirmation?: string }).confirmation;
|
||||||
|
|
||||||
|
const result: UploadFileResult = {
|
||||||
|
fileId,
|
||||||
|
filePath,
|
||||||
|
run: null,
|
||||||
|
deletion: null,
|
||||||
|
unified: false,
|
||||||
|
raw: uploadJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("fileUploaded", {
|
||||||
|
detail: {
|
||||||
|
file,
|
||||||
|
result: {
|
||||||
|
file_id: fileId,
|
||||||
|
file_path: filePath,
|
||||||
|
filename: filePath,
|
||||||
|
pages: pages,
|
||||||
|
content_length: contentLength,
|
||||||
|
confirmation: confirmation,
|
||||||
|
response_id: fileId,
|
||||||
|
run: null,
|
||||||
|
deletion: null,
|
||||||
|
unified: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("fileUploadError", {
|
||||||
|
detail: {
|
||||||
|
filename: file.name,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Upload failed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
window.dispatchEvent(new CustomEvent("fileUploadComplete"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadFile(
|
||||||
|
file: File,
|
||||||
|
replace = false
|
||||||
|
): Promise<UploadFileResult> {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("fileUploadStart", {
|
||||||
|
detail: { filename: file.name },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
formData.append("replace_duplicates", replace.toString());
|
||||||
|
|
||||||
|
const uploadResponse = await fetch("/api/router/upload_ingest", {
|
||||||
|
method: "POST",
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = await uploadResponse.json();
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error("Upload failed: unable to parse server response");
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadIngestJson =
|
||||||
|
typeof payload === "object" && payload !== null ? payload : {};
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
const errorMessage =
|
||||||
|
(uploadIngestJson as { error?: string }).error ||
|
||||||
|
"Upload and ingest failed";
|
||||||
|
throw new Error(errorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId =
|
||||||
|
(uploadIngestJson as { upload?: { id?: string } }).upload?.id ||
|
||||||
|
(uploadIngestJson as { id?: string }).id ||
|
||||||
|
(uploadIngestJson as { task_id?: string }).task_id;
|
||||||
|
const filePath =
|
||||||
|
(uploadIngestJson as { upload?: { path?: string } }).upload?.path ||
|
||||||
|
(uploadIngestJson as { path?: string }).path ||
|
||||||
|
"uploaded";
|
||||||
|
const runJson = (uploadIngestJson as { ingestion?: unknown }).ingestion;
|
||||||
|
const deletionJson = (uploadIngestJson as { deletion?: unknown }).deletion;
|
||||||
|
|
||||||
|
if (!fileId) {
|
||||||
|
throw new Error("Upload successful but no file id returned");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
runJson &&
|
||||||
|
typeof runJson === "object" &&
|
||||||
|
"status" in (runJson as Record<string, unknown>) &&
|
||||||
|
(runJson as { status?: string }).status !== "COMPLETED" &&
|
||||||
|
(runJson as { status?: string }).status !== "SUCCESS"
|
||||||
|
) {
|
||||||
|
const errorMsg =
|
||||||
|
(runJson as { error?: string }).error ||
|
||||||
|
"Ingestion pipeline failed";
|
||||||
|
throw new Error(
|
||||||
|
`Ingestion failed: ${errorMsg}. Try setting DISABLE_INGEST_WITH_LANGFLOW=true if you're experiencing Langflow component issues.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result: UploadFileResult = {
|
||||||
|
fileId,
|
||||||
|
filePath,
|
||||||
|
run: runJson,
|
||||||
|
deletion: deletionJson,
|
||||||
|
unified: true,
|
||||||
|
raw: uploadIngestJson,
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("fileUploaded", {
|
||||||
|
detail: {
|
||||||
|
file,
|
||||||
|
result: {
|
||||||
|
file_id: fileId,
|
||||||
|
file_path: filePath,
|
||||||
|
run: runJson,
|
||||||
|
deletion: deletionJson,
|
||||||
|
unified: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("fileUploadError", {
|
||||||
|
detail: {
|
||||||
|
filename: file.name,
|
||||||
|
error:
|
||||||
|
error instanceof Error ? error.message : "Upload failed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
window.dispatchEvent(new CustomEvent("fileUploadComplete"));
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
|
|
@ -52,6 +52,7 @@
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
@ -10224,6 +10225,15 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-stick-to-bottom": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/use-stick-to-bottom/-/use-stick-to-bottom-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-JkDp0b0tSmv7HQOOpL1hT7t7QaoUBXkq045WWWOFDTlLGRzgIIyW7vyzOIJzY7L2XVIG7j1yUxeDj2LHm9Vwng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/use-sync-external-store": {
|
"node_modules/use-sync-external-store": {
|
||||||
"version": "1.5.0",
|
"version": "1.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
"sonner": "^2.0.6",
|
"sonner": "^2.0.6",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"use-stick-to-bottom": "^1.1.1",
|
||||||
"zustand": "^5.0.8"
|
"zustand": "^5.0.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|
|
||||||
|
|
@ -106,7 +106,6 @@ async function proxyRequest(
|
||||||
}
|
}
|
||||||
const response = await fetch(backendUrl, init);
|
const response = await fetch(backendUrl, init);
|
||||||
|
|
||||||
const responseBody = await response.text();
|
|
||||||
const responseHeaders = new Headers();
|
const responseHeaders = new Headers();
|
||||||
|
|
||||||
// Copy response headers
|
// Copy response headers
|
||||||
|
|
@ -117,11 +116,22 @@ async function proxyRequest(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For streaming responses, pass the body directly without buffering
|
||||||
|
if (response.body) {
|
||||||
|
return new NextResponse(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers: responseHeaders,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback for non-streaming responses
|
||||||
|
const responseBody = await response.text();
|
||||||
return new NextResponse(responseBody, {
|
return new NextResponse(responseBody, {
|
||||||
status: response.status,
|
status: response.status,
|
||||||
statusText: response.statusText,
|
statusText: response.statusText,
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Proxy error:', error);
|
console.error('Proxy error:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|
|
||||||
|
|
@ -158,8 +158,8 @@ function AuthCallbackContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-card rounded-lg m-4">
|
||||||
<Card className="w-full max-w-md">
|
<Card className="w-full max-w-md bg-card rounded-lg m-4">
|
||||||
<CardHeader className="text-center">
|
<CardHeader className="text-center">
|
||||||
<CardTitle className="flex items-center justify-center gap-2">
|
<CardTitle className="flex items-center justify-center gap-2">
|
||||||
{status === "processing" && (
|
{status === "processing" && (
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { Bot, GitBranch } from "lucide-react";
|
import { GitBranch } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import DogIcon from "@/components/logo/dog-icon";
|
||||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { FunctionCall } from "../types";
|
||||||
import { FunctionCalls } from "./function-calls";
|
import { FunctionCalls } from "./function-calls";
|
||||||
import { Message } from "./message";
|
import { Message } from "./message";
|
||||||
import type { FunctionCall } from "../types";
|
|
||||||
import DogIcon from "@/components/logo/dog-icon";
|
|
||||||
|
|
||||||
interface AssistantMessageProps {
|
interface AssistantMessageProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
|
@ -14,6 +16,9 @@ interface AssistantMessageProps {
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
showForkButton?: boolean;
|
showForkButton?: boolean;
|
||||||
onFork?: (e: React.MouseEvent) => void;
|
onFork?: (e: React.MouseEvent) => void;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
delay?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantMessage({
|
export function AssistantMessage({
|
||||||
|
|
@ -25,20 +30,31 @@ export function AssistantMessage({
|
||||||
isStreaming = false,
|
isStreaming = false,
|
||||||
showForkButton = false,
|
showForkButton = false,
|
||||||
onFork,
|
onFork,
|
||||||
|
isCompleted = false,
|
||||||
|
animate = true,
|
||||||
|
delay = 0.2,
|
||||||
}: AssistantMessageProps) {
|
}: AssistantMessageProps) {
|
||||||
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
|
|
||||||
const IconComponent = updatedOnboarding ? DogIcon : Bot;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={animate ? { duration: 0.4, delay: delay, ease: "easeOut" } : { duration: 0 }}
|
||||||
|
className={isCompleted ? "opacity-50" : ""}
|
||||||
|
>
|
||||||
<Message
|
<Message
|
||||||
icon={
|
icon={
|
||||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||||
<IconComponent className="h-4 w-4 text-accent-foreground" />
|
<DogIcon
|
||||||
|
className="h-6 w-6 transition-colors duration-300"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
showForkButton && onFork ? (
|
showForkButton && onFork ? (
|
||||||
<button
|
<button
|
||||||
|
type="button"
|
||||||
onClick={onFork}
|
onClick={onFork}
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
|
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground"
|
||||||
title="Fork conversation from here"
|
title="Fork conversation from here"
|
||||||
|
|
@ -54,10 +70,18 @@ export function AssistantMessage({
|
||||||
expandedFunctionCalls={expandedFunctionCalls}
|
expandedFunctionCalls={expandedFunctionCalls}
|
||||||
onToggle={onToggle}
|
onToggle={onToggle}
|
||||||
/>
|
/>
|
||||||
<MarkdownRenderer chatMessage={content} />
|
<div className="relative">
|
||||||
{isStreaming && (
|
<MarkdownRenderer
|
||||||
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
|
className={cn("text-sm py-1.5 transition-colors duration-300", isCompleted ? "text-placeholder-foreground" : "text-foreground")}
|
||||||
)}
|
chatMessage={
|
||||||
|
isStreaming
|
||||||
|
? content +
|
||||||
|
' <span class="inline-block w-1 h-4 bg-primary ml-1 animate-pulse"></span>'
|
||||||
|
: content
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</Message>
|
</Message>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Check, Funnel, Loader2, Plus, X } from "lucide-react";
|
import { ArrowRight, Check, Funnel, Loader2, Plus, X } from "lucide-react";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
|
||||||
import { forwardRef, useImperativeHandle, useRef } from "react";
|
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
import type { FilterColor } from "@/components/filter-icon-popover";
|
||||||
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
|
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
|
|
@ -9,7 +10,6 @@ import {
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import type { KnowledgeFilterData } from "../types";
|
import type { KnowledgeFilterData } from "../types";
|
||||||
import { FilterColor } from "@/components/filter-icon-popover";
|
|
||||||
|
|
||||||
export interface ChatInputHandle {
|
export interface ChatInputHandle {
|
||||||
focusInput: () => void;
|
focusInput: () => void;
|
||||||
|
|
@ -41,7 +41,8 @@ interface ChatInputProps {
|
||||||
setIsFilterDropdownOpen: (open: boolean) => void;
|
setIsFilterDropdownOpen: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
|
||||||
|
(
|
||||||
{
|
{
|
||||||
input,
|
input,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -66,7 +67,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
setIsFilterHighlighted,
|
setIsFilterHighlighted,
|
||||||
setIsFilterDropdownOpen,
|
setIsFilterDropdownOpen,
|
||||||
},
|
},
|
||||||
ref
|
ref,
|
||||||
) => {
|
) => {
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -81,34 +82,45 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pb-8 pt-4 flex px-6">
|
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<form onSubmit={onSubmit} className="relative">
|
<form onSubmit={onSubmit} className="relative">
|
||||||
<div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring">
|
<div className="relative flex items-center w-full p-2 gap-2 rounded-xl border border-input focus-within:ring-1 focus-within:ring-ring">
|
||||||
{selectedFilter && (
|
{selectedFilter ? (
|
||||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
|
||||||
<span
|
<span
|
||||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
|
className={`inline-flex items-center p-1 rounded-sm text-xs font-medium transition-colors ${
|
||||||
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@filter:{selectedFilter.name}
|
{selectedFilter.name}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedFilter(null);
|
setSelectedFilter(null);
|
||||||
setIsFilterHighlighted(false);
|
setIsFilterHighlighted(false);
|
||||||
}}
|
}}
|
||||||
className="ml-1 rounded-full p-0.5"
|
className="ml-0.5 rounded-full p-0.5"
|
||||||
>
|
>
|
||||||
<X className="h-3 w-3" />
|
<X className="h-4 w-4" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
) : (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
className="h-8 w-8 p-0 rounded-md hover:bg-muted/50"
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={onAtClick}
|
||||||
|
data-filter-button
|
||||||
|
>
|
||||||
|
<Funnel className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="relative"
|
className="relative flex-1"
|
||||||
style={{ height: `${textareaHeight + 60}px` }}
|
style={{ height: `${textareaHeight}px` }}
|
||||||
>
|
>
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
|
|
@ -117,20 +129,36 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
onHeightChange={onHeightChange}
|
onHeightChange={onHeightChange}
|
||||||
maxRows={7}
|
maxRows={7}
|
||||||
minRows={2}
|
minRows={1}
|
||||||
placeholder="Type to ask a question..."
|
placeholder="Ask a question..."
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className={`w-full bg-transparent px-4 ${
|
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
|
||||||
selectedFilter ? "pt-2" : "pt-4"
|
rows={1}
|
||||||
} focus-visible:outline-none resize-none`}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
{/* Safe area at bottom for buttons */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
|
|
||||||
style={{ height: "60px" }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="iconSm"
|
||||||
|
onClick={onFilePickerClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="h-8 w-8 p-0 !rounded-md hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
type="submit"
|
||||||
|
size="iconSm"
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
className="!rounded-md h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
|
|
@ -139,22 +167,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
className="hidden"
|
className="hidden"
|
||||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||||
/>
|
/>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="iconSm"
|
|
||||||
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
|
||||||
onMouseDown={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={onAtClick}
|
|
||||||
data-filter-button
|
|
||||||
>
|
|
||||||
<Funnel className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Popover
|
<Popover
|
||||||
open={isFilterDropdownOpen}
|
open={isFilterDropdownOpen}
|
||||||
onOpenChange={open => {
|
onOpenChange={(open) => {
|
||||||
setIsFilterDropdownOpen(open);
|
setIsFilterDropdownOpen(open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
@ -179,7 +195,7 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
align="start"
|
align="start"
|
||||||
sideOffset={6}
|
sideOffset={6}
|
||||||
alignOffset={-18}
|
alignOffset={-18}
|
||||||
onOpenAutoFocus={e => {
|
onOpenAutoFocus={(e) => {
|
||||||
// Prevent auto focus on the popover content
|
// Prevent auto focus on the popover content
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
// Keep focus on the input
|
// Keep focus on the input
|
||||||
|
|
@ -212,10 +228,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{availableFilters
|
{availableFilters
|
||||||
.filter(filter =>
|
.filter((filter) =>
|
||||||
filter.name
|
filter.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(filterSearchTerm.toLowerCase())
|
.includes(filterSearchTerm.toLowerCase()),
|
||||||
)
|
)
|
||||||
.map((filter, index) => (
|
.map((filter, index) => (
|
||||||
<button
|
<button
|
||||||
|
|
@ -241,10 +257,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
{availableFilters.filter(filter =>
|
{availableFilters.filter((filter) =>
|
||||||
filter.name
|
filter.name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(filterSearchTerm.toLowerCase())
|
.includes(filterSearchTerm.toLowerCase()),
|
||||||
).length === 0 &&
|
).length === 0 &&
|
||||||
filterSearchTerm && (
|
filterSearchTerm && (
|
||||||
<div className="px-2 py-3 text-sm text-muted-foreground">
|
<div className="px-2 py-3 text-sm text-muted-foreground">
|
||||||
|
|
@ -256,27 +272,10 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
</div>
|
</div>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="iconSm"
|
|
||||||
onClick={onFilePickerClick}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!input.trim() || loading}
|
|
||||||
className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
ChatInput.displayName = "ChatInput";
|
ChatInput.displayName = "ChatInput";
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,58 @@
|
||||||
import { User } from "lucide-react";
|
import { FileText, User } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { Message } from "./message";
|
import { Message } from "./message";
|
||||||
|
|
||||||
interface UserMessageProps {
|
interface UserMessageProps {
|
||||||
content: string;
|
content: string;
|
||||||
|
isCompleted?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
files?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserMessage({ content }: UserMessageProps) {
|
export function UserMessage({ content, isCompleted, animate = true, files }: UserMessageProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={animate ? { opacity: 0, y: -20 } : { opacity: 1, y: 0 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={animate ? { duration: 0.4, delay: 0.2, ease: "easeOut" } : { duration: 0 }}
|
||||||
|
className={isCompleted ? "opacity-50" : ""}
|
||||||
|
>
|
||||||
<Message
|
<Message
|
||||||
icon={
|
icon={
|
||||||
<Avatar className="w-8 h-8 flex-shrink-0 select-none">
|
<Avatar className="w-8 h-8 rounded-lg flex-shrink-0 select-none">
|
||||||
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
|
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
|
||||||
<AvatarFallback className="text-sm bg-primary/20 text-primary">
|
<AvatarFallback
|
||||||
{user?.name ? (
|
className={cn(
|
||||||
user.name.charAt(0).toUpperCase()
|
isCompleted ? "text-placeholder-foreground" : "text-primary",
|
||||||
) : (
|
"text-sm bg-accent/20 rounded-lg transition-colors duration-300",
|
||||||
<User className="h-4 w-4" />
|
|
||||||
)}
|
)}
|
||||||
|
>
|
||||||
|
{user?.name ? user.name.charAt(0).toUpperCase() : <User className="h-4 w-4" />}
|
||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
{files && (
|
||||||
|
<p className="text-muted-foreground flex items-center gap-2 font-normal text-mmd py-1.5 whitespace-pre-wrap break-words overflow-wrap-anywhere transition-colors duration-300">
|
||||||
|
<FileText className="h-4 w-4" />
|
||||||
|
{files}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
"text-foreground text-sm py-1.5 whitespace-pre-wrap break-words overflow-wrap-anywhere transition-colors duration-300",
|
||||||
|
isCompleted ? "text-placeholder-foreground" : "text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
{content}
|
{content}
|
||||||
</p>
|
</p>
|
||||||
</Message>
|
</Message>
|
||||||
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
export default function Nudges({
|
export default function Nudges({
|
||||||
nudges,
|
nudges,
|
||||||
|
onboarding,
|
||||||
handleSuggestionClick,
|
handleSuggestionClick,
|
||||||
}: {
|
}: {
|
||||||
nudges: string[];
|
nudges: string[];
|
||||||
|
onboarding?: boolean;
|
||||||
handleSuggestionClick: (suggestion: string) => void;
|
handleSuggestionClick: (suggestion: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -21,14 +23,21 @@ export default function Nudges({
|
||||||
ease: "easeInOut",
|
ease: "easeInOut",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="relative px-6 pt-4 flex justify-center">
|
<div
|
||||||
<div className="w-full max-w-[75%]">
|
className="relative flex"
|
||||||
|
>
|
||||||
|
<div className="w-full">
|
||||||
<div className="flex gap-3 justify-start overflow-x-auto scrollbar-hide">
|
<div className="flex gap-3 justify-start overflow-x-auto scrollbar-hide">
|
||||||
{nudges.map((suggestion: string, index: number) => (
|
{nudges.map((suggestion: string, index: number) => (
|
||||||
<button
|
<button
|
||||||
key={index}
|
key={index}
|
||||||
onClick={() => handleSuggestionClick(suggestion)}
|
onClick={() => handleSuggestionClick(suggestion)}
|
||||||
className="px-2 py-1.5 bg-muted hover:bg-muted/50 rounded-lg text-sm text-placeholder-foreground hover:text-foreground transition-colors whitespace-nowrap"
|
className={cn(
|
||||||
|
onboarding
|
||||||
|
? "text-foreground"
|
||||||
|
: "text-placeholder-foreground hover:text-foreground",
|
||||||
|
"bg-background border hover:bg-background/50 px-2 py-1.5 rounded-lg text-sm transition-colors whitespace-nowrap",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{suggestion}
|
{suggestion}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ export interface Message {
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
functionCalls?: FunctionCall[];
|
functionCalls?: FunctionCall[];
|
||||||
isStreaming?: boolean;
|
isStreaming?: boolean;
|
||||||
|
source?: "langflow" | "chat";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FunctionCall {
|
export interface FunctionCall {
|
||||||
|
|
|
||||||
|
|
@ -109,15 +109,12 @@
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.app-grid-arrangement {
|
.app-grid-arrangement {
|
||||||
--sidebar-width: 0px;
|
|
||||||
--notifications-width: 0px;
|
--notifications-width: 0px;
|
||||||
--filters-width: 0px;
|
--filters-width: 0px;
|
||||||
--app-header-height: 53px;
|
|
||||||
--top-banner-height: 0px;
|
--top-banner-height: 0px;
|
||||||
|
--header-height: 54px;
|
||||||
|
--sidebar-width: 280px;
|
||||||
|
|
||||||
@media (width >= 48rem) {
|
|
||||||
--sidebar-width: 288px;
|
|
||||||
}
|
|
||||||
&.notifications-open {
|
&.notifications-open {
|
||||||
--notifications-width: 320px;
|
--notifications-width: 320px;
|
||||||
}
|
}
|
||||||
|
|
@ -132,7 +129,7 @@
|
||||||
width: 100%;
|
width: 100%;
|
||||||
grid-template-rows:
|
grid-template-rows:
|
||||||
var(--top-banner-height)
|
var(--top-banner-height)
|
||||||
var(--app-header-height)
|
var(--header-height)
|
||||||
1fr;
|
1fr;
|
||||||
grid-template-columns:
|
grid-template-columns:
|
||||||
var(--sidebar-width)
|
var(--sidebar-width)
|
||||||
|
|
@ -147,10 +144,6 @@
|
||||||
grid-template-rows 0.25s ease-in-out;
|
grid-template-rows 0.25s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-arrangement {
|
|
||||||
@apply flex w-full items-center justify-between border-b border-border;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-start-display {
|
.header-start-display {
|
||||||
@apply flex items-center gap-2;
|
@apply flex items-center gap-2;
|
||||||
}
|
}
|
||||||
|
|
@ -352,6 +345,15 @@
|
||||||
@apply text-xs opacity-70;
|
@apply text-xs opacity-70;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.prose :where(strong):not(:where([class~="not-prose"],[class~="not-prose"] *)) {
|
||||||
|
@apply text-current;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prose :where(a):not(:where([class~="not-prose"],[class~="not-prose"] *))
|
||||||
|
{
|
||||||
|
@apply text-current;
|
||||||
|
}
|
||||||
|
|
||||||
.box-shadow-inner::after {
|
.box-shadow-inner::after {
|
||||||
content: " ";
|
content: " ";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ export default function RootLayout({
|
||||||
return (
|
return (
|
||||||
<html lang="en" suppressHydrationWarning>
|
<html lang="en" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${inter.variable} ${jetbrainsMono.variable} ${chivo.variable} antialiased h-lvh w-full overflow-hidden`}
|
className={`${inter.variable} ${jetbrainsMono.variable} ${chivo.variable} antialiased h-lvh w-full overflow-hidden bg-black`}
|
||||||
>
|
>
|
||||||
<ThemeProvider
|
<ThemeProvider
|
||||||
attribute="class"
|
attribute="class"
|
||||||
|
|
|
||||||
|
|
@ -6,40 +6,29 @@ import { Suspense, useEffect } from "react";
|
||||||
import GoogleLogo from "@/components/logo/google-logo";
|
import GoogleLogo from "@/components/logo/google-logo";
|
||||||
import Logo from "@/components/logo/logo";
|
import Logo from "@/components/logo/logo";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { DotPattern } from "@/components/ui/dot-pattern";
|
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
import { useGetSettingsQuery } from "../api/queries/useGetSettingsQuery";
|
|
||||||
|
|
||||||
function LoginPageContent() {
|
function LoginPageContent() {
|
||||||
const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth();
|
const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
|
const redirect = searchParams.get("redirect") || "/chat";
|
||||||
enabled: isAuthenticated || isNoAuthMode,
|
|
||||||
});
|
|
||||||
|
|
||||||
const redirect =
|
|
||||||
settings && !settings.edited
|
|
||||||
? "/onboarding"
|
|
||||||
: searchParams.get("redirect") || "/chat";
|
|
||||||
|
|
||||||
// Redirect if already authenticated or in no-auth mode
|
// Redirect if already authenticated or in no-auth mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isSettingsLoading && (isAuthenticated || isNoAuthMode)) {
|
if (!isLoading && (isAuthenticated || isNoAuthMode)) {
|
||||||
router.push(redirect);
|
router.push(redirect);
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
isLoading,
|
isLoading,
|
||||||
isSettingsLoading,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isNoAuthMode,
|
isNoAuthMode,
|
||||||
router,
|
router,
|
||||||
redirect,
|
redirect,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (isLoading || isSettingsLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|
@ -55,21 +44,10 @@ function LoginPageContent() {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh relative flex gap-4 flex-col items-center justify-center bg-background p-4">
|
<div className="min-h-dvh relative flex gap-4 flex-col items-center justify-center bg-card rounded-lg m-4">
|
||||||
<DotPattern
|
|
||||||
width={24}
|
|
||||||
height={24}
|
|
||||||
cx={1}
|
|
||||||
cy={1}
|
|
||||||
cr={1}
|
|
||||||
className={cn(
|
|
||||||
"[mask-image:linear-gradient(to_bottom,white,transparent,transparent)]",
|
|
||||||
"text-input/70",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-4 z-10 ">
|
<div className="flex flex-col items-center justify-center gap-4 z-10 ">
|
||||||
<Logo className="fill-primary" width={32} height={28} />
|
<Logo className="fill-primary" width={50} height={40} />
|
||||||
<div className="flex flex-col items-center justify-center gap-8">
|
<div className="flex flex-col items-center justify-center gap-16">
|
||||||
<h1 className="text-2xl font-medium font-chivo">Welcome to OpenRAG</h1>
|
<h1 className="text-2xl font-medium font-chivo">Welcome to OpenRAG</h1>
|
||||||
<Button onClick={login} className="w-80 gap-1.5" size="lg">
|
<Button onClick={login} className="w-80 gap-1.5" size="lg">
|
||||||
<GoogleLogo className="h-4 w-4" />
|
<GoogleLogo className="h-4 w-4" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,150 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { StickToBottom } from "use-stick-to-bottom";
|
||||||
|
import { AssistantMessage } from "@/app/chat/components/assistant-message";
|
||||||
|
import { UserMessage } from "@/app/chat/components/user-message";
|
||||||
|
import Nudges from "@/app/chat/nudges";
|
||||||
|
import type { Message } from "@/app/chat/types";
|
||||||
|
import OnboardingCard from "@/app/onboarding/components/onboarding-card";
|
||||||
|
import { useChatStreaming } from "@/hooks/useChatStreaming";
|
||||||
|
|
||||||
|
import { OnboardingStep } from "./onboarding-step";
|
||||||
|
import OnboardingUpload from "./onboarding-upload";
|
||||||
|
|
||||||
|
export function OnboardingContent({
|
||||||
|
handleStepComplete,
|
||||||
|
currentStep,
|
||||||
|
}: {
|
||||||
|
handleStepComplete: () => void;
|
||||||
|
currentStep: number;
|
||||||
|
}) {
|
||||||
|
const [responseId, setResponseId] = useState<string | null>(null);
|
||||||
|
const [selectedNudge, setSelectedNudge] = useState<string>("");
|
||||||
|
const [assistantMessage, setAssistantMessage] = useState<Message | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = useState<boolean>(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState<string[]>([]);
|
||||||
|
const [hasStartedOnboarding, setHasStartedOnboarding] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const { streamingMessage, isLoading, sendMessage } = useChatStreaming({
|
||||||
|
onComplete: (message, newResponseId) => {
|
||||||
|
setAssistantMessage(message);
|
||||||
|
if (newResponseId) {
|
||||||
|
setResponseId(newResponseId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Chat error:", error);
|
||||||
|
setAssistantMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"Sorry, I couldn't connect to the chat service. Please try again.",
|
||||||
|
timestamp: new Date(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const NUDGES = ["What is OpenRAG?"];
|
||||||
|
|
||||||
|
const handleNudgeClick = async (nudge: string) => {
|
||||||
|
setSelectedNudge(nudge);
|
||||||
|
setAssistantMessage(null);
|
||||||
|
setTimeout(async () => {
|
||||||
|
await sendMessage({
|
||||||
|
prompt: nudge,
|
||||||
|
previousResponseId: responseId || undefined,
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine which message to show (streaming takes precedence)
|
||||||
|
const displayMessage = streamingMessage || assistantMessage;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === 1 && !isLoading && !!displayMessage) {
|
||||||
|
handleStepComplete();
|
||||||
|
}
|
||||||
|
}, [isLoading, displayMessage, handleStepComplete, currentStep]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StickToBottom
|
||||||
|
className="flex h-full flex-1 flex-col"
|
||||||
|
resize="smooth"
|
||||||
|
initial="instant"
|
||||||
|
mass={1}
|
||||||
|
>
|
||||||
|
<StickToBottom.Content className="flex flex-col min-h-full overflow-x-hidden px-8 py-6">
|
||||||
|
<div className="flex flex-col place-self-center w-full space-y-6">
|
||||||
|
{/* Step 1 */}
|
||||||
|
<OnboardingStep
|
||||||
|
isVisible={currentStep >= 0}
|
||||||
|
isCompleted={currentStep > 0}
|
||||||
|
text="Let's get started by setting up your model provider."
|
||||||
|
isLoadingModels={isLoadingModels}
|
||||||
|
loadingStatus={loadingStatus}
|
||||||
|
reserveSpaceForThinking={!hasStartedOnboarding}
|
||||||
|
>
|
||||||
|
<OnboardingCard
|
||||||
|
onComplete={() => {
|
||||||
|
setHasStartedOnboarding(true);
|
||||||
|
handleStepComplete();
|
||||||
|
}}
|
||||||
|
setIsLoadingModels={setIsLoadingModels}
|
||||||
|
setLoadingStatus={setLoadingStatus}
|
||||||
|
/>
|
||||||
|
</OnboardingStep>
|
||||||
|
|
||||||
|
{/* Step 2 */}
|
||||||
|
<OnboardingStep
|
||||||
|
isVisible={currentStep >= 1}
|
||||||
|
isCompleted={currentStep > 1 || !!selectedNudge}
|
||||||
|
text="Excellent, let's move on to learning the basics."
|
||||||
|
>
|
||||||
|
<div className="py-2">
|
||||||
|
<Nudges
|
||||||
|
onboarding
|
||||||
|
nudges={NUDGES}
|
||||||
|
handleSuggestionClick={handleNudgeClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</OnboardingStep>
|
||||||
|
|
||||||
|
{/* User message - show when nudge is selected */}
|
||||||
|
{currentStep >= 1 && !!selectedNudge && (
|
||||||
|
<UserMessage
|
||||||
|
content={selectedNudge}
|
||||||
|
isCompleted={currentStep > 2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Assistant message - show streaming or final message */}
|
||||||
|
{currentStep >= 1 &&
|
||||||
|
!!selectedNudge &&
|
||||||
|
(displayMessage || isLoading) && (
|
||||||
|
<AssistantMessage
|
||||||
|
content={displayMessage?.content || ""}
|
||||||
|
functionCalls={displayMessage?.functionCalls}
|
||||||
|
messageIndex={0}
|
||||||
|
expandedFunctionCalls={new Set()}
|
||||||
|
onToggle={() => {}}
|
||||||
|
isStreaming={!!streamingMessage}
|
||||||
|
isCompleted={currentStep > 2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3 */}
|
||||||
|
<OnboardingStep
|
||||||
|
isVisible={currentStep >= 2 && !isLoading && !!displayMessage}
|
||||||
|
isCompleted={currentStep > 2}
|
||||||
|
text="Lastly, let's add your data."
|
||||||
|
hideIcon={true}
|
||||||
|
>
|
||||||
|
<OnboardingUpload onComplete={handleStepComplete} />
|
||||||
|
</OnboardingStep>
|
||||||
|
</div>
|
||||||
|
</StickToBottom.Content>
|
||||||
|
</StickToBottom>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,18 +1,61 @@
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { motion, AnimatePresence } from "motion/react";
|
import { type ReactNode, useEffect, useState } from "react";
|
||||||
import { Message } from "@/app/chat/components/message";
|
import { Message } from "@/app/chat/components/message";
|
||||||
import DogIcon from "@/components/logo/dog-icon";
|
import DogIcon from "@/components/logo/dog-icon";
|
||||||
|
import { AnimatedProcessingIcon } from "@/components/ui/animated-processing-icon";
|
||||||
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface OnboardingStepProps {
|
interface OnboardingStepProps {
|
||||||
text: string;
|
text: string;
|
||||||
children: ReactNode;
|
children?: ReactNode;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isCompleted?: boolean;
|
isCompleted?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
isMarkdown?: boolean;
|
||||||
|
hideIcon?: boolean;
|
||||||
|
isLoadingModels?: boolean;
|
||||||
|
loadingStatus?: string[];
|
||||||
|
reserveSpaceForThinking?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingStep({ text, children, isVisible, isCompleted = false }: OnboardingStepProps) {
|
export function OnboardingStep({
|
||||||
|
text,
|
||||||
|
children,
|
||||||
|
isVisible,
|
||||||
|
isCompleted = false,
|
||||||
|
icon,
|
||||||
|
isMarkdown = false,
|
||||||
|
hideIcon = false,
|
||||||
|
isLoadingModels = false,
|
||||||
|
loadingStatus = [],
|
||||||
|
reserveSpaceForThinking = false,
|
||||||
|
}: OnboardingStepProps) {
|
||||||
const [displayedText, setDisplayedText] = useState("");
|
const [displayedText, setDisplayedText] = useState("");
|
||||||
const [showChildren, setShowChildren] = useState(false);
|
const [showChildren, setShowChildren] = useState(false);
|
||||||
|
const [currentStatusIndex, setCurrentStatusIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
// Cycle through loading status messages once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingModels || loadingStatus.length === 0) {
|
||||||
|
setCurrentStatusIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentStatusIndex((prev) => {
|
||||||
|
const nextIndex = prev + 1;
|
||||||
|
// Stop at the last message
|
||||||
|
if (nextIndex >= loadingStatus.length - 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return loadingStatus.length - 1;
|
||||||
|
}
|
||||||
|
return nextIndex;
|
||||||
|
});
|
||||||
|
}, 1500); // Change status every 1.5 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isLoadingModels, loadingStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVisible) {
|
if (!isVisible) {
|
||||||
|
|
@ -21,6 +64,12 @@ export function OnboardingStep({ text, children, isVisible, isCompleted = false
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isCompleted) {
|
||||||
|
setDisplayedText(text);
|
||||||
|
setShowChildren(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
setDisplayedText("");
|
setDisplayedText("");
|
||||||
setShowChildren(false);
|
setShowChildren(false);
|
||||||
|
|
@ -33,44 +82,109 @@ export function OnboardingStep({ text, children, isVisible, isCompleted = false
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setShowChildren(true);
|
setShowChildren(true);
|
||||||
}
|
}
|
||||||
}, 10); // 10ms per character
|
}, 20); // 20ms per character
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [text, isVisible]);
|
}, [text, isVisible, isCompleted]);
|
||||||
|
|
||||||
if (!isVisible) return null;
|
if (!isVisible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: -20 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }}
|
||||||
className={isCompleted ? "opacity-50" : ""}
|
className={isCompleted ? "opacity-50" : ""}
|
||||||
>
|
>
|
||||||
<Message
|
<Message
|
||||||
icon={
|
icon={
|
||||||
|
hideIcon ? (
|
||||||
|
<div className="w-8 h-8 rounded-lg flex-shrink-0" />
|
||||||
|
) : (
|
||||||
|
icon || (
|
||||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||||
<DogIcon className="h-6 w-6 text-accent-foreground" disabled={isCompleted} />
|
<DogIcon
|
||||||
|
className="h-6 w-6 text-accent-foreground transition-colors duration-300"
|
||||||
|
disabled={isCompleted}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div>
|
||||||
<p className={`text-foreground text-sm py-1.5 ${isCompleted ? "text-placeholder-foreground" : ""}`}>
|
{isLoadingModels && loadingStatus.length > 0 ? (
|
||||||
|
<div className="flex flex-col gap-2 py-1.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative w-1.5 h-2.5">
|
||||||
|
<AnimatedProcessingIcon className="text-current shrink-0 absolute inset-0" />
|
||||||
|
</div>
|
||||||
|
<span className="text-mmd font-medium text-muted-foreground">
|
||||||
|
Thinking
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<div className="flex items-center gap-5 overflow-y-hidden relative h-6">
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
<div className="relative h-5 w-full">
|
||||||
|
<AnimatePresence mode="sync" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={currentStatusIndex}
|
||||||
|
initial={{ y: 24, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -24, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
|
className="text-mmd font-medium text-primary absolute left-0"
|
||||||
|
>
|
||||||
|
{loadingStatus[currentStatusIndex]}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isMarkdown ? (
|
||||||
|
<MarkdownRenderer
|
||||||
|
className={cn(
|
||||||
|
isCompleted
|
||||||
|
? "text-placeholder-foreground"
|
||||||
|
: "text-foreground",
|
||||||
|
"text-sm py-1.5 transition-colors duration-300",
|
||||||
|
)}
|
||||||
|
chatMessage={text}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p
|
||||||
|
className={`text-foreground text-sm py-1.5 transition-colors duration-300 ${
|
||||||
|
isCompleted ? "text-placeholder-foreground" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{displayedText}
|
{displayedText}
|
||||||
{!showChildren && !isCompleted && <span className="inline-block w-1 h-4 bg-primary ml-1 animate-pulse" />}
|
{!showChildren && !isCompleted && (
|
||||||
|
<span className="inline-block w-1 h-3.5 bg-primary ml-1 animate-pulse" />
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
|
{reserveSpaceForThinking && (
|
||||||
|
<div className="h-8" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{children && (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showChildren && !isCompleted && (
|
{((showChildren && !isCompleted) || isMarkdown) && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: -10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, height: 0 }}
|
exit={{ opacity: 0, height: 0 }}
|
||||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }}
|
||||||
>
|
>
|
||||||
{children}
|
<div className="pt-2">
|
||||||
|
{children}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Message>
|
</Message>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
|
||||||
108
frontend/src/app/new-onboarding/components/onboarding-upload.tsx
Normal file
108
frontend/src/app/new-onboarding/components/onboarding-upload.tsx
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { ChangeEvent, useRef, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { uploadFileForContext } from "@/lib/upload-utils";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps";
|
||||||
|
|
||||||
|
interface OnboardingUploadProps {
|
||||||
|
onComplete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OnboardingUpload = ({ onComplete }: OnboardingUploadProps) => {
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
const [currentStep, setCurrentStep] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const STEP_LIST = [
|
||||||
|
"Uploading your document",
|
||||||
|
"Processing your document",
|
||||||
|
];
|
||||||
|
|
||||||
|
const resetFileInput = () => {
|
||||||
|
if (fileInputRef.current) {
|
||||||
|
fileInputRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUploadClick = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const performUpload = async (file: File) => {
|
||||||
|
setIsUploading(true);
|
||||||
|
try {
|
||||||
|
setCurrentStep(0);
|
||||||
|
await uploadFileForContext(file);
|
||||||
|
console.log("Document uploaded successfully");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Upload failed", (error as Error).message);
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
setCurrentStep(STEP_LIST.length);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const selectedFile = event.target.files?.[0];
|
||||||
|
if (!selectedFile) {
|
||||||
|
resetFileInput();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performUpload(selectedFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Unable to prepare file for upload", (error as Error).message);
|
||||||
|
} finally {
|
||||||
|
resetFileInput();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AnimatePresence mode="wait">
|
||||||
|
{currentStep === null ? (
|
||||||
|
<motion.div
|
||||||
|
key="user-ingest"
|
||||||
|
initial={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -24 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUploadClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{isUploading ? "Uploading..." : "Add a Document"}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="ingest-steps"
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<AnimatedProviderSteps
|
||||||
|
currentStep={currentStep}
|
||||||
|
setCurrentStep={setCurrentStep}
|
||||||
|
steps={STEP_LIST}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnboardingUpload;
|
||||||
|
|
@ -8,17 +8,17 @@ export function ProgressBar({ currentStep, totalSteps }: ProgressBarProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<div className="flex items-center max-w-md mx-auto gap-3">
|
<div className="flex items-center max-w-48 mx-auto gap-3">
|
||||||
<div className="flex-1 h-2 bg-background rounded-full overflow-hidden">
|
<div className="flex-1 h-1 bg-background rounded-full overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className="h-full transition-all duration-300 ease-in-out"
|
className="h-full transition-all duration-300 ease-in-out"
|
||||||
style={{
|
style={{
|
||||||
width: `${progressPercentage}%`,
|
width: `${progressPercentage}%`,
|
||||||
background: 'linear-gradient(to right, #818CF8, #F472B6)'
|
background: 'linear-gradient(to right, #818CF8, #22A7AF)'
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
{currentStep + 1}/{totalSteps}
|
{currentStep + 1}/{totalSteps}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Suspense, useState } from "react";
|
import { Suspense, useState } from "react";
|
||||||
import { ProtectedRoute } from "@/components/protected-route";
|
|
||||||
import { DoclingHealthBanner } from "@/components/docling-health-banner";
|
import { DoclingHealthBanner } from "@/components/docling-health-banner";
|
||||||
import { DotPattern } from "@/components/ui/dot-pattern";
|
import { ProtectedRoute } from "@/components/protected-route";
|
||||||
import { cn } from "@/lib/utils";
|
import { OnboardingContent } from "./components/onboarding-content";
|
||||||
import { OnboardingStep } from "./components/onboarding-step";
|
|
||||||
import { ProgressBar } from "./components/progress-bar";
|
import { ProgressBar } from "./components/progress-bar";
|
||||||
import OnboardingCard from "../onboarding/components/onboarding-card";
|
|
||||||
|
|
||||||
const TOTAL_STEPS = 4;
|
const TOTAL_STEPS = 4;
|
||||||
|
|
||||||
|
|
@ -27,69 +24,7 @@ function NewOnboardingPage() {
|
||||||
{/* Chat-like content area */}
|
{/* Chat-like content area */}
|
||||||
<div className="flex flex-col items-center gap-5 w-full max-w-3xl z-10">
|
<div className="flex flex-col items-center gap-5 w-full max-w-3xl z-10">
|
||||||
<div className="w-full h-[872px] bg-background border rounded-lg p-4 shadow-sm overflow-y-auto">
|
<div className="w-full h-[872px] bg-background border rounded-lg p-4 shadow-sm overflow-y-auto">
|
||||||
<div className="space-y-6">
|
<OnboardingContent handleStepComplete={handleStepComplete} currentStep={currentStep} />
|
||||||
<OnboardingStep
|
|
||||||
isVisible={currentStep >= 0}
|
|
||||||
isCompleted={currentStep > 0}
|
|
||||||
text="Let's get started by setting up your model provider."
|
|
||||||
>
|
|
||||||
<OnboardingCard onComplete={handleStepComplete} />
|
|
||||||
</OnboardingStep>
|
|
||||||
|
|
||||||
<OnboardingStep
|
|
||||||
isVisible={currentStep >= 1}
|
|
||||||
isCompleted={currentStep > 1}
|
|
||||||
text="Step 1: Configure your settings"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Let's configure some basic settings for your account.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleStepComplete}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</OnboardingStep>
|
|
||||||
|
|
||||||
<OnboardingStep
|
|
||||||
isVisible={currentStep >= 2}
|
|
||||||
isCompleted={currentStep > 2}
|
|
||||||
text="Step 2: Connect your model"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Choose and connect your preferred AI model provider.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={handleStepComplete}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</OnboardingStep>
|
|
||||||
|
|
||||||
<OnboardingStep
|
|
||||||
isVisible={currentStep >= 3}
|
|
||||||
isCompleted={currentStep > 3}
|
|
||||||
text="Step 3: You're all set!"
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-muted-foreground">
|
|
||||||
Your account is ready to use. Let's start chatting!
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => window.location.href = "/chat"}
|
|
||||||
className="px-4 py-2 bg-primary text-primary-foreground rounded-lg hover:bg-primary/90"
|
|
||||||
>
|
|
||||||
Go to Chat
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</OnboardingStep>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ProgressBar currentStep={currentStep} totalSteps={TOTAL_STEPS} />
|
<ProgressBar currentStep={currentStep} totalSteps={TOTAL_STEPS} />
|
||||||
|
|
|
||||||
|
|
@ -75,20 +75,6 @@ export function AdvancedOnboarding({
|
||||||
/>
|
/>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
)}
|
)}
|
||||||
{(hasLanguageModels || hasEmbeddingModels) && !updatedOnboarding && <Separator />}
|
|
||||||
{!updatedOnboarding && (
|
|
||||||
<LabelWrapper
|
|
||||||
label="Sample dataset"
|
|
||||||
description="Load sample data to chat with immediately."
|
|
||||||
id="sample-dataset"
|
|
||||||
flex
|
|
||||||
>
|
|
||||||
<Switch
|
|
||||||
checked={sampleDataset}
|
|
||||||
onCheckedChange={setSampleDataset}
|
|
||||||
/>
|
|
||||||
</LabelWrapper>
|
|
||||||
)}
|
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { CheckIcon } from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { AnimatedProcessingIcon } from "@/components/ui/animated-processing-icon";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function AnimatedProviderSteps({
|
||||||
|
currentStep,
|
||||||
|
setCurrentStep,
|
||||||
|
steps,
|
||||||
|
}: {
|
||||||
|
currentStep: number;
|
||||||
|
setCurrentStep: (step: number) => void;
|
||||||
|
steps: string[];
|
||||||
|
}) {
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep < steps.length - 1) {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
}, 1500);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}
|
||||||
|
}, [currentStep, setCurrentStep, steps]);
|
||||||
|
|
||||||
|
const isDone = currentStep >= steps.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"transition-all duration-150 relative",
|
||||||
|
isDone ? "w-3.5 h-3.5" : "w-1.5 h-2.5",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CheckIcon
|
||||||
|
className={cn(
|
||||||
|
"text-accent-emerald-foreground shrink-0 w-3.5 h-3.5 absolute inset-0 transition-all duration-150",
|
||||||
|
isDone ? "opacity-100" : "opacity-0",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<AnimatedProcessingIcon
|
||||||
|
className={cn(
|
||||||
|
"text-current shrink-0 absolute inset-0 transition-all duration-150",
|
||||||
|
isDone ? "opacity-0" : "opacity-100",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="text-mmd font-medium text-muted-foreground">
|
||||||
|
{isDone ? "Done" : "Thinking"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden">
|
||||||
|
<AnimatePresence>
|
||||||
|
{!isDone && (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 1, y: 0, height: "auto" }}
|
||||||
|
exit={{ opacity: 0, y: -24, height: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
className="flex items-center gap-5 overflow-y-hidden relative h-6"
|
||||||
|
>
|
||||||
|
<div className="w-px h-6 bg-border" />
|
||||||
|
<div className="relative h-5 w-full">
|
||||||
|
<AnimatePresence mode="sync" initial={false}>
|
||||||
|
<motion.span
|
||||||
|
key={currentStep}
|
||||||
|
initial={{ y: 24, opacity: 0 }}
|
||||||
|
animate={{ y: 0, opacity: 1 }}
|
||||||
|
exit={{ y: -24, opacity: 0 }}
|
||||||
|
transition={{ duration: 0.3, ease: "easeInOut" }}
|
||||||
|
className="text-mmd font-medium text-primary absolute left-0"
|
||||||
|
>
|
||||||
|
{steps[currentStep]}
|
||||||
|
</motion.span>
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LabelInput } from "@/components/label-input";
|
import { LabelInput } from "@/components/label-input";
|
||||||
import { LabelWrapper } from "@/components/label-wrapper";
|
import { LabelWrapper } from "@/components/label-wrapper";
|
||||||
import IBMLogo from "@/components/logo/ibm-logo";
|
import IBMLogo from "@/components/logo/ibm-logo";
|
||||||
|
|
@ -14,10 +14,14 @@ export function IBMOnboarding({
|
||||||
setSettings,
|
setSettings,
|
||||||
sampleDataset,
|
sampleDataset,
|
||||||
setSampleDataset,
|
setSampleDataset,
|
||||||
|
setIsLoadingModels,
|
||||||
|
setLoadingStatus,
|
||||||
}: {
|
}: {
|
||||||
setSettings: (settings: OnboardingVariables) => void;
|
setSettings: (settings: OnboardingVariables) => void;
|
||||||
sampleDataset: boolean;
|
sampleDataset: boolean;
|
||||||
setSampleDataset: (dataset: boolean) => void;
|
setSampleDataset: (dataset: boolean) => void;
|
||||||
|
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||||
|
setLoadingStatus?: (status: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [endpoint, setEndpoint] = useState("https://us-south.ml.cloud.ibm.com");
|
const [endpoint, setEndpoint] = useState("https://us-south.ml.cloud.ibm.com");
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
|
|
@ -99,6 +103,19 @@ export function IBMOnboarding({
|
||||||
},
|
},
|
||||||
setSettings,
|
setSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify parent about loading state
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoadingModels?.(isLoadingModels);
|
||||||
|
|
||||||
|
// Set detailed loading status
|
||||||
|
if (isLoadingModels) {
|
||||||
|
const status = ["Connecting to IBM watsonx.ai", "Fetching language models", "Fetching embedding models"];
|
||||||
|
setLoadingStatus?.(status);
|
||||||
|
} else {
|
||||||
|
setLoadingStatus?.([]);
|
||||||
|
}
|
||||||
|
}, [isLoadingModels, setIsLoadingModels, setLoadingStatus]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,20 @@ import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutat
|
||||||
import { useGetOllamaModelsQuery } from "../../api/queries/useGetModelsQuery";
|
import { useGetOllamaModelsQuery } from "../../api/queries/useGetModelsQuery";
|
||||||
import { useModelSelection } from "../hooks/useModelSelection";
|
import { useModelSelection } from "../hooks/useModelSelection";
|
||||||
import { useUpdateSettings } from "../hooks/useUpdateSettings";
|
import { useUpdateSettings } from "../hooks/useUpdateSettings";
|
||||||
import { AdvancedOnboarding } from "./advanced";
|
|
||||||
import { ModelSelector } from "./model-selector";
|
import { ModelSelector } from "./model-selector";
|
||||||
|
|
||||||
export function OllamaOnboarding({
|
export function OllamaOnboarding({
|
||||||
setSettings,
|
setSettings,
|
||||||
sampleDataset,
|
sampleDataset,
|
||||||
setSampleDataset,
|
setSampleDataset,
|
||||||
|
setIsLoadingModels,
|
||||||
|
setLoadingStatus,
|
||||||
}: {
|
}: {
|
||||||
setSettings: (settings: OnboardingVariables) => void;
|
setSettings: (settings: OnboardingVariables) => void;
|
||||||
sampleDataset: boolean;
|
sampleDataset: boolean;
|
||||||
setSampleDataset: (dataset: boolean) => void;
|
setSampleDataset: (dataset: boolean) => void;
|
||||||
|
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||||
|
setLoadingStatus?: (status: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [endpoint, setEndpoint] = useState(`http://localhost:11434`);
|
const [endpoint, setEndpoint] = useState(`http://localhost:11434`);
|
||||||
const [showConnecting, setShowConnecting] = useState(false);
|
const [showConnecting, setShowConnecting] = useState(false);
|
||||||
|
|
@ -61,10 +64,6 @@ export function OllamaOnboarding({
|
||||||
};
|
};
|
||||||
}, [debouncedEndpoint, isLoadingModels]);
|
}, [debouncedEndpoint, isLoadingModels]);
|
||||||
|
|
||||||
const handleSampleDatasetChange = (dataset: boolean) => {
|
|
||||||
setSampleDataset(dataset);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update settings when values change
|
// Update settings when values change
|
||||||
useUpdateSettings(
|
useUpdateSettings(
|
||||||
"ollama",
|
"ollama",
|
||||||
|
|
@ -76,6 +75,19 @@ export function OllamaOnboarding({
|
||||||
setSettings,
|
setSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify parent about loading state
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoadingModels?.(isLoadingModels);
|
||||||
|
|
||||||
|
// Set detailed loading status
|
||||||
|
if (isLoadingModels) {
|
||||||
|
const status = ["Connecting to Ollama", "Fetching language models", "Fetching embedding models"];
|
||||||
|
setLoadingStatus?.(status);
|
||||||
|
} else {
|
||||||
|
setLoadingStatus?.([]);
|
||||||
|
}
|
||||||
|
}, [isLoadingModels, setIsLoadingModels, setLoadingStatus]);
|
||||||
|
|
||||||
// Check validation state based on models query
|
// Check validation state based on models query
|
||||||
const hasConnectionError = debouncedEndpoint && modelsError;
|
const hasConnectionError = debouncedEndpoint && modelsError;
|
||||||
const hasNoModels =
|
const hasNoModels =
|
||||||
|
|
@ -84,7 +96,6 @@ export function OllamaOnboarding({
|
||||||
!modelsData.embedding_models?.length;
|
!modelsData.embedding_models?.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<LabelInput
|
<LabelInput
|
||||||
|
|
@ -151,10 +162,5 @@ export function OllamaOnboarding({
|
||||||
/>
|
/>
|
||||||
</LabelWrapper>
|
</LabelWrapper>
|
||||||
</div>
|
</div>
|
||||||
<AdvancedOnboarding
|
|
||||||
sampleDataset={sampleDataset}
|
|
||||||
setSampleDataset={handleSampleDatasetChange}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,18 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
type OnboardingVariables,
|
type OnboardingVariables,
|
||||||
useOnboardingMutation,
|
useOnboardingMutation,
|
||||||
} from "@/app/api/mutations/useOnboardingMutation";
|
} from "@/app/api/mutations/useOnboardingMutation";
|
||||||
|
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
|
||||||
import { useDoclingHealth } from "@/components/docling-health-banner";
|
import { useDoclingHealth } from "@/components/docling-health-banner";
|
||||||
import IBMLogo from "@/components/logo/ibm-logo";
|
import IBMLogo from "@/components/logo/ibm-logo";
|
||||||
import OllamaLogo from "@/components/logo/ollama-logo";
|
import OllamaLogo from "@/components/logo/ollama-logo";
|
||||||
import OpenAILogo from "@/components/logo/openai-logo";
|
import OpenAILogo from "@/components/logo/openai-logo";
|
||||||
|
import { AnimatedProcessingIcon } from "@/components/ui/animated-processing-icon";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -23,23 +26,76 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { AnimatedProviderSteps } from "./animated-provider-steps";
|
||||||
import { IBMOnboarding } from "./ibm-onboarding";
|
import { IBMOnboarding } from "./ibm-onboarding";
|
||||||
import { OllamaOnboarding } from "./ollama-onboarding";
|
import { OllamaOnboarding } from "./ollama-onboarding";
|
||||||
import { OpenAIOnboarding } from "./openai-onboarding";
|
import { OpenAIOnboarding } from "./openai-onboarding";
|
||||||
|
|
||||||
interface OnboardingCardProps {
|
interface OnboardingCardProps {
|
||||||
onComplete: () => void;
|
onComplete: () => void;
|
||||||
|
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||||
|
setLoadingStatus?: (status: string[]) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
|
|
||||||
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
|
|
||||||
const { isHealthy: isDoclingHealthy } = useDoclingHealth();
|
|
||||||
|
|
||||||
|
const STEP_LIST = [
|
||||||
|
"Setting up your model provider",
|
||||||
|
"Defining schema",
|
||||||
|
"Configuring Langflow",
|
||||||
|
"Ingesting sample data",
|
||||||
|
];
|
||||||
|
|
||||||
|
const TOTAL_PROVIDER_STEPS = STEP_LIST.length;
|
||||||
|
|
||||||
|
const OnboardingCard = ({
|
||||||
|
onComplete,
|
||||||
|
setIsLoadingModels: setIsLoadingModelsParent,
|
||||||
|
setLoadingStatus: setLoadingStatusParent,
|
||||||
|
}: OnboardingCardProps) => {
|
||||||
|
const { isHealthy: isDoclingHealthy } = useDoclingHealth();
|
||||||
|
|
||||||
const [modelProvider, setModelProvider] = useState<string>("openai");
|
const [modelProvider, setModelProvider] = useState<string>("openai");
|
||||||
|
|
||||||
const [sampleDataset, setSampleDataset] = useState<boolean>(true);
|
const [sampleDataset, setSampleDataset] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const [isLoadingModels, setIsLoadingModels] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const [currentStatusIndex, setCurrentStatusIndex] = useState<number>(0);
|
||||||
|
|
||||||
|
// Pass loading state to parent
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoadingModelsParent?.(isLoadingModels);
|
||||||
|
}, [isLoadingModels, setIsLoadingModelsParent]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingStatusParent?.(loadingStatus);
|
||||||
|
}, [loadingStatus, setLoadingStatusParent]);
|
||||||
|
|
||||||
|
// Cycle through loading status messages once
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoadingModels || loadingStatus.length === 0) {
|
||||||
|
setCurrentStatusIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
setCurrentStatusIndex((prev) => {
|
||||||
|
const nextIndex = prev + 1;
|
||||||
|
// Stop at the last message
|
||||||
|
if (nextIndex >= loadingStatus.length - 1) {
|
||||||
|
clearInterval(interval);
|
||||||
|
return loadingStatus.length - 1;
|
||||||
|
}
|
||||||
|
return nextIndex;
|
||||||
|
});
|
||||||
|
}, 1500); // Change status every 1.5 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [isLoadingModels, loadingStatus]);
|
||||||
|
|
||||||
const handleSetModelProvider = (provider: string) => {
|
const handleSetModelProvider = (provider: string) => {
|
||||||
setModelProvider(provider);
|
setModelProvider(provider);
|
||||||
setSettings({
|
setSettings({
|
||||||
|
|
@ -55,11 +111,47 @@ const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
|
||||||
llm_model: "",
|
llm_model: "",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [currentStep, setCurrentStep] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// Query tasks to track completion
|
||||||
|
const { data: tasks } = useGetTasksQuery({
|
||||||
|
enabled: currentStep !== null, // Only poll when onboarding has started
|
||||||
|
refetchInterval: currentStep !== null ? 1000 : false, // Poll every 1 second during onboarding
|
||||||
|
});
|
||||||
|
|
||||||
|
// Monitor tasks and call onComplete when all tasks are done
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentStep === null || !tasks) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there are any active tasks (pending, running, or processing)
|
||||||
|
const activeTasks = tasks.find(
|
||||||
|
(task) =>
|
||||||
|
task.status === "pending" ||
|
||||||
|
task.status === "running" ||
|
||||||
|
task.status === "processing",
|
||||||
|
);
|
||||||
|
|
||||||
|
// If no active tasks and we've started onboarding, complete it
|
||||||
|
if (
|
||||||
|
(!activeTasks || (activeTasks.processed_files ?? 0) > 0) &&
|
||||||
|
tasks.length > 0
|
||||||
|
) {
|
||||||
|
// Set to final step to show "Done"
|
||||||
|
setCurrentStep(TOTAL_PROVIDER_STEPS);
|
||||||
|
// Wait a bit before completing
|
||||||
|
setTimeout(() => {
|
||||||
|
onComplete();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
}, [tasks, currentStep, onComplete]);
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
const onboardingMutation = useOnboardingMutation({
|
const onboardingMutation = useOnboardingMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
console.log("Onboarding completed successfully", data);
|
console.log("Onboarding completed successfully", data);
|
||||||
onComplete();
|
setCurrentStep(0);
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast.error("Failed to complete onboarding", {
|
toast.error("Failed to complete onboarding", {
|
||||||
|
|
@ -102,38 +194,64 @@ const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
onboardingMutation.mutate(onboardingData);
|
onboardingMutation.mutate(onboardingData);
|
||||||
|
setCurrentStep(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isComplete = !!settings.llm_model && !!settings.embedding_model && isDoclingHealthy;
|
const isComplete =
|
||||||
|
!!settings.llm_model && !!settings.embedding_model && isDoclingHealthy;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className={`w-full max-w-[600px] ${updatedOnboarding ? "border-none" : ""}`}>
|
<AnimatePresence mode="wait">
|
||||||
|
{currentStep === null ? (
|
||||||
|
<motion.div
|
||||||
|
key="onboarding-form"
|
||||||
|
initial={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -24 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<div className={`w-full max-w-[600px] flex flex-col gap-6`}>
|
||||||
<Tabs
|
<Tabs
|
||||||
defaultValue={modelProvider}
|
defaultValue={modelProvider}
|
||||||
onValueChange={handleSetModelProvider}
|
onValueChange={handleSetModelProvider}
|
||||||
>
|
>
|
||||||
<CardHeader className={`${updatedOnboarding ? "px-0" : ""}`}>
|
<TabsList className="mb-4">
|
||||||
<TabsList>
|
<TabsTrigger
|
||||||
<TabsTrigger value="openai">
|
value="openai"
|
||||||
<OpenAILogo className="w-4 h-4" />
|
>
|
||||||
|
<div className={cn("flex items-center justify-center gap-2 w-8 h-8 rounded-md", modelProvider === "openai" ? "bg-white" : "bg-muted")}>
|
||||||
|
<OpenAILogo className={cn("w-4 h-4 shrink-0", modelProvider === "openai" ? "text-black" : "text-muted-foreground")} />
|
||||||
|
</div>
|
||||||
OpenAI
|
OpenAI
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="watsonx">
|
<TabsTrigger
|
||||||
<IBMLogo className="w-4 h-4" />
|
value="watsonx"
|
||||||
|
>
|
||||||
|
<div className={cn("flex items-center justify-center gap-2 w-8 h-8 rounded-md", modelProvider === "watsonx" ? "bg-[#1063FE]" : "bg-muted")}>
|
||||||
|
<IBMLogo className={cn("w-4 h-4 shrink-0", modelProvider === "watsonx" ? "text-white" : "text-muted-foreground")} />
|
||||||
|
</div>
|
||||||
IBM watsonx.ai
|
IBM watsonx.ai
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="ollama">
|
<TabsTrigger
|
||||||
<OllamaLogo className="w-4 h-4" />
|
value="ollama"
|
||||||
|
>
|
||||||
|
<div className={cn("flex items-center justify-center gap-2 w-8 h-8 rounded-md", modelProvider === "ollama" ? "bg-white" : "bg-muted")}>
|
||||||
|
<OllamaLogo
|
||||||
|
className={cn(
|
||||||
|
"w-4 h-4 shrink-0",
|
||||||
|
modelProvider === "ollama" ? "text-black" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
Ollama
|
Ollama
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</CardHeader>
|
|
||||||
<CardContent className={`${updatedOnboarding ? "px-0" : ""}`}>
|
|
||||||
<TabsContent value="openai">
|
<TabsContent value="openai">
|
||||||
<OpenAIOnboarding
|
<OpenAIOnboarding
|
||||||
setSettings={setSettings}
|
setSettings={setSettings}
|
||||||
sampleDataset={sampleDataset}
|
sampleDataset={sampleDataset}
|
||||||
setSampleDataset={setSampleDataset}
|
setSampleDataset={setSampleDataset}
|
||||||
|
setIsLoadingModels={setIsLoadingModels}
|
||||||
|
setLoadingStatus={setLoadingStatus}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="watsonx">
|
<TabsContent value="watsonx">
|
||||||
|
|
@ -141,6 +259,8 @@ const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
|
||||||
setSettings={setSettings}
|
setSettings={setSettings}
|
||||||
sampleDataset={sampleDataset}
|
sampleDataset={sampleDataset}
|
||||||
setSampleDataset={setSampleDataset}
|
setSampleDataset={setSampleDataset}
|
||||||
|
setIsLoadingModels={setIsLoadingModels}
|
||||||
|
setLoadingStatus={setLoadingStatus}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="ollama">
|
<TabsContent value="ollama">
|
||||||
|
|
@ -148,11 +268,13 @@ const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
|
||||||
setSettings={setSettings}
|
setSettings={setSettings}
|
||||||
sampleDataset={sampleDataset}
|
sampleDataset={sampleDataset}
|
||||||
setSampleDataset={setSampleDataset}
|
setSampleDataset={setSampleDataset}
|
||||||
|
setIsLoadingModels={setIsLoadingModels}
|
||||||
|
setLoadingStatus={setLoadingStatus}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</CardContent>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<CardFooter className={`flex ${updatedOnboarding ? "px-0" : "justify-end"}`}>
|
|
||||||
|
{!isLoadingModels && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -168,15 +290,33 @@ const OnboardingCard = ({ onComplete }: OnboardingCardProps) => {
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
{!isComplete && (
|
{!isComplete && (
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
{!!settings.llm_model && !!settings.embedding_model && !isDoclingHealthy
|
{!!settings.llm_model &&
|
||||||
|
!!settings.embedding_model &&
|
||||||
|
!isDoclingHealthy
|
||||||
? "docling-serve must be running to continue"
|
? "docling-serve must be running to continue"
|
||||||
: "Please fill in all required fields"}
|
: "Please fill in all required fields"}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
)}
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</CardFooter>
|
)}
|
||||||
</Card>
|
</div>
|
||||||
)
|
</motion.div>
|
||||||
}
|
) : (
|
||||||
|
<motion.div
|
||||||
|
key="provider-steps"
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
transition={{ duration: 0.4, ease: "easeInOut" }}
|
||||||
|
>
|
||||||
|
<AnimatedProviderSteps
|
||||||
|
currentStep={currentStep}
|
||||||
|
setCurrentStep={setCurrentStep}
|
||||||
|
steps={STEP_LIST}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default OnboardingCard;
|
export default OnboardingCard;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { LabelInput } from "@/components/label-input";
|
import { LabelInput } from "@/components/label-input";
|
||||||
import { LabelWrapper } from "@/components/label-wrapper";
|
import { LabelWrapper } from "@/components/label-wrapper";
|
||||||
import OpenAILogo from "@/components/logo/openai-logo";
|
import OpenAILogo from "@/components/logo/openai-logo";
|
||||||
|
|
@ -14,10 +14,14 @@ export function OpenAIOnboarding({
|
||||||
setSettings,
|
setSettings,
|
||||||
sampleDataset,
|
sampleDataset,
|
||||||
setSampleDataset,
|
setSampleDataset,
|
||||||
|
setIsLoadingModels,
|
||||||
|
setLoadingStatus,
|
||||||
}: {
|
}: {
|
||||||
setSettings: (settings: OnboardingVariables) => void;
|
setSettings: (settings: OnboardingVariables) => void;
|
||||||
sampleDataset: boolean;
|
sampleDataset: boolean;
|
||||||
setSampleDataset: (dataset: boolean) => void;
|
setSampleDataset: (dataset: boolean) => void;
|
||||||
|
setIsLoadingModels?: (isLoading: boolean) => void;
|
||||||
|
setLoadingStatus?: (status: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const [apiKey, setApiKey] = useState("");
|
const [apiKey, setApiKey] = useState("");
|
||||||
const [getFromEnv, setGetFromEnv] = useState(true);
|
const [getFromEnv, setGetFromEnv] = useState(true);
|
||||||
|
|
@ -68,6 +72,19 @@ export function OpenAIOnboarding({
|
||||||
},
|
},
|
||||||
setSettings,
|
setSettings,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify parent about loading state
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoadingModels?.(isLoadingModels);
|
||||||
|
|
||||||
|
// Set detailed loading status
|
||||||
|
if (isLoadingModels) {
|
||||||
|
const status = ["Connecting to OpenAI", "Fetching language models", "Fetching embedding models"];
|
||||||
|
setLoadingStatus?.(status);
|
||||||
|
} else {
|
||||||
|
setLoadingStatus?.([]);
|
||||||
|
}
|
||||||
|
}, [isLoadingModels, setIsLoadingModels, setLoadingStatus]);
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
|
|
|
||||||
53
frontend/src/components/animated-conditional.tsx
Normal file
53
frontend/src/components/animated-conditional.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { ANIMATION_DURATION } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const AnimatedConditional = ({
|
||||||
|
children,
|
||||||
|
isOpen,
|
||||||
|
className,
|
||||||
|
slide = false,
|
||||||
|
delay,
|
||||||
|
vertical = false,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isOpen: boolean;
|
||||||
|
className?: string;
|
||||||
|
delay?: number;
|
||||||
|
vertical?: boolean;
|
||||||
|
slide?: boolean;
|
||||||
|
}) => {
|
||||||
|
const animationProperty = slide
|
||||||
|
? vertical
|
||||||
|
? "translateY"
|
||||||
|
: "translateX"
|
||||||
|
: vertical
|
||||||
|
? "height"
|
||||||
|
: "width";
|
||||||
|
const animationValue = isOpen
|
||||||
|
? slide
|
||||||
|
? "0px"
|
||||||
|
: "auto"
|
||||||
|
: slide
|
||||||
|
? "-100%"
|
||||||
|
: "0px";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ [animationProperty]: animationValue }}
|
||||||
|
animate={{ [animationProperty]: animationValue }}
|
||||||
|
exit={{ [animationProperty]: 0 }}
|
||||||
|
transition={{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
ease: "easeOut",
|
||||||
|
delay: delay,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
overflow: "hidden",
|
||||||
|
whiteSpace: vertical ? "normal" : "nowrap",
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
203
frontend/src/components/chat-renderer.tsx
Normal file
203
frontend/src/components/chat-renderer.tsx
Normal file
|
|
@ -0,0 +1,203 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
type ChatConversation,
|
||||||
|
useGetConversationsQuery,
|
||||||
|
} from "@/app/api/queries/useGetConversationsQuery";
|
||||||
|
import type { Settings } from "@/app/api/queries/useGetSettingsQuery";
|
||||||
|
import { OnboardingContent } from "@/app/new-onboarding/components/onboarding-content";
|
||||||
|
import { ProgressBar } from "@/app/new-onboarding/components/progress-bar";
|
||||||
|
import { AnimatedConditional } from "@/components/animated-conditional";
|
||||||
|
import { Header } from "@/components/header";
|
||||||
|
import { Navigation } from "@/components/navigation";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
import { useChat } from "@/contexts/chat-context";
|
||||||
|
import {
|
||||||
|
ANIMATION_DURATION,
|
||||||
|
HEADER_HEIGHT,
|
||||||
|
ONBOARDING_STEP_KEY,
|
||||||
|
SIDEBAR_WIDTH,
|
||||||
|
TOTAL_ONBOARDING_STEPS,
|
||||||
|
} from "@/lib/constants";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function ChatRenderer({
|
||||||
|
settings,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
settings: Settings | undefined;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { isAuthenticated, isNoAuthMode } = useAuth();
|
||||||
|
const {
|
||||||
|
endpoint,
|
||||||
|
refreshTrigger,
|
||||||
|
refreshConversations,
|
||||||
|
startNewConversation,
|
||||||
|
} = useChat();
|
||||||
|
|
||||||
|
// Initialize onboarding state based on local storage and settings
|
||||||
|
const [currentStep, setCurrentStep] = useState<number>(() => {
|
||||||
|
if (typeof window === "undefined") return 0;
|
||||||
|
const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY);
|
||||||
|
return savedStep !== null ? parseInt(savedStep, 10) : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showLayout, setShowLayout] = useState<boolean>(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
const savedStep = localStorage.getItem(ONBOARDING_STEP_KEY);
|
||||||
|
// Show layout if settings.edited is true and if no onboarding step is saved
|
||||||
|
const isEdited = settings?.edited ?? true;
|
||||||
|
return isEdited ? savedStep === null : false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only fetch conversations on chat page
|
||||||
|
const isOnChatPage = pathname === "/" || pathname === "/chat";
|
||||||
|
const { data: conversations = [], isLoading: isConversationsLoading } =
|
||||||
|
useGetConversationsQuery(endpoint, refreshTrigger, {
|
||||||
|
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
|
||||||
|
}) as { data: ChatConversation[]; isLoading: boolean };
|
||||||
|
|
||||||
|
const handleNewConversation = () => {
|
||||||
|
refreshConversations();
|
||||||
|
startNewConversation();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save current step to local storage whenever it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window !== "undefined" && !showLayout) {
|
||||||
|
localStorage.setItem(ONBOARDING_STEP_KEY, currentStep.toString());
|
||||||
|
}
|
||||||
|
}, [currentStep, showLayout]);
|
||||||
|
|
||||||
|
const handleStepComplete = () => {
|
||||||
|
if (currentStep < TOTAL_ONBOARDING_STEPS - 1) {
|
||||||
|
setCurrentStep(currentStep + 1);
|
||||||
|
} else {
|
||||||
|
// Onboarding is complete - remove from local storage and show layout
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem(ONBOARDING_STEP_KEY);
|
||||||
|
}
|
||||||
|
setShowLayout(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// List of paths with smaller max-width
|
||||||
|
const smallWidthPaths = ["/settings/connector/new"];
|
||||||
|
const isSmallWidthPath = smallWidthPaths.includes(pathname);
|
||||||
|
|
||||||
|
const x = showLayout ? "0px" : `calc(-${SIDEBAR_WIDTH / 2}px + 50vw)`;
|
||||||
|
const y = showLayout ? "0px" : `calc(-${HEADER_HEIGHT / 2}px + 50vh)`;
|
||||||
|
const translateY = showLayout ? "0px" : `-50vh`;
|
||||||
|
const translateX = showLayout ? "0px" : `-50vw`;
|
||||||
|
|
||||||
|
// For all other pages, render with Langflow-styled navigation and task menu
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<AnimatedConditional
|
||||||
|
className="[grid-area:header] bg-background border-b"
|
||||||
|
vertical
|
||||||
|
slide
|
||||||
|
isOpen={showLayout}
|
||||||
|
delay={ANIMATION_DURATION / 2}
|
||||||
|
>
|
||||||
|
<Header />
|
||||||
|
</AnimatedConditional>
|
||||||
|
|
||||||
|
{/* Sidebar Navigation */}
|
||||||
|
<AnimatedConditional
|
||||||
|
isOpen={showLayout}
|
||||||
|
slide
|
||||||
|
className={`border-r bg-background overflow-hidden [grid-area:nav] w-[${SIDEBAR_WIDTH}px]`}
|
||||||
|
>
|
||||||
|
<Navigation
|
||||||
|
conversations={conversations}
|
||||||
|
isConversationsLoading={isConversationsLoading}
|
||||||
|
onNewConversation={handleNewConversation}
|
||||||
|
/>
|
||||||
|
</AnimatedConditional>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="overflow-hidden w-full flex items-center justify-center [grid-area:main]">
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
width: showLayout ? "100%" : "100vw",
|
||||||
|
height: showLayout ? "100%" : "100vh",
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
translateX: translateX,
|
||||||
|
translateY: translateY,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
width: showLayout ? "100%" : "850px",
|
||||||
|
borderRadius: showLayout ? "0" : "16px",
|
||||||
|
border: showLayout ? "0" : "1px solid #27272A",
|
||||||
|
height: showLayout ? "100%" : "800px",
|
||||||
|
x: x,
|
||||||
|
y: y,
|
||||||
|
translateX: translateX,
|
||||||
|
translateY: translateY,
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
ease: "easeOut",
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex h-full w-full max-w-full max-h-full items-center justify-center overflow-hidden",
|
||||||
|
!showLayout && "absolute",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"h-full bg-background w-full",
|
||||||
|
showLayout && !isOnChatPage && "p-6 container overflow-y-auto",
|
||||||
|
showLayout && isSmallWidthPath && "max-w-[850px] ml-0",
|
||||||
|
!showLayout &&
|
||||||
|
"w-full bg-card rounded-lg shadow-2xl p-0 py-2 overflow-y-auto",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<motion.div
|
||||||
|
initial={{
|
||||||
|
opacity: showLayout ? 1 : 0,
|
||||||
|
}}
|
||||||
|
animate={{
|
||||||
|
opacity: "100%",
|
||||||
|
}}
|
||||||
|
transition={{
|
||||||
|
duration: ANIMATION_DURATION,
|
||||||
|
ease: "easeOut",
|
||||||
|
delay: ANIMATION_DURATION,
|
||||||
|
}}
|
||||||
|
className={cn("w-full h-full 0v")}
|
||||||
|
>
|
||||||
|
<div className={cn("w-full h-full", !showLayout && "hidden")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{!showLayout && (
|
||||||
|
<OnboardingContent
|
||||||
|
handleStepComplete={handleStepComplete}
|
||||||
|
currentStep={currentStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 20 }}
|
||||||
|
animate={{ opacity: showLayout ? 0 : 1, y: showLayout ? 20 : 0 }}
|
||||||
|
transition={{ duration: ANIMATION_DURATION, ease: "easeOut" }}
|
||||||
|
className={cn("absolute bottom-10 left-0 right-0")}
|
||||||
|
>
|
||||||
|
<ProgressBar
|
||||||
|
currentStep={currentStep}
|
||||||
|
totalSteps={TOTAL_ONBOARDING_STEPS}
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
|
</main>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
frontend/src/components/header.tsx
Normal file
60
frontend/src/components/header.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Bell } from "lucide-react";
|
||||||
|
import Logo from "@/components/logo/logo";
|
||||||
|
import { UserNav } from "@/components/user-nav";
|
||||||
|
import { useTask } from "@/contexts/task-context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export function Header() {
|
||||||
|
const { tasks, toggleMenu } = useTask();
|
||||||
|
|
||||||
|
// Calculate active tasks for the bell icon
|
||||||
|
const activeTasks = tasks.filter(
|
||||||
|
(task) =>
|
||||||
|
task.status === "pending" ||
|
||||||
|
task.status === "running" ||
|
||||||
|
task.status === "processing",
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={cn(`flex w-full h-full items-center justify-between`)}>
|
||||||
|
<div className="header-start-display px-[16px]">
|
||||||
|
{/* Logo/Title */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<Logo className="fill-primary" width={24} height={22} />
|
||||||
|
<span className="text-lg font-semibold pl-2.5">OpenRAG</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="header-end-division">
|
||||||
|
<div className="justify-end flex items-center">
|
||||||
|
{/* Knowledge Filter Dropdown */}
|
||||||
|
{/* <KnowledgeFilterDropdown
|
||||||
|
selectedFilter={selectedFilter}
|
||||||
|
onFilterSelect={setSelectedFilter}
|
||||||
|
/> */}
|
||||||
|
|
||||||
|
{/* GitHub Star Button */}
|
||||||
|
{/* <GitHubStarButton repo="phact/openrag" /> */}
|
||||||
|
|
||||||
|
{/* Discord Link */}
|
||||||
|
{/* <DiscordLink inviteCode="EqksyE2EX9" /> */}
|
||||||
|
|
||||||
|
{/* Task Notification Bell */}
|
||||||
|
<button
|
||||||
|
onClick={toggleMenu}
|
||||||
|
className="relative h-8 w-8 hover:bg-muted rounded-lg flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<Bell size={16} className="text-muted-foreground" />
|
||||||
|
{activeTasks.length > 0 && <div className="header-notifications" />}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Separator */}
|
||||||
|
<div className="w-px h-6 bg-border mx-3" />
|
||||||
|
|
||||||
|
<UserNav />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,39 +1,25 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Bell, Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import {
|
|
||||||
useGetConversationsQuery,
|
|
||||||
type ChatConversation,
|
|
||||||
} from "@/app/api/queries/useGetConversationsQuery";
|
|
||||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
||||||
import { DoclingHealthBanner } from "@/components/docling-health-banner";
|
import { DoclingHealthBanner } from "@/components/docling-health-banner";
|
||||||
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
|
import { KnowledgeFilterPanel } from "@/components/knowledge-filter-panel";
|
||||||
import Logo from "@/components/logo/logo";
|
|
||||||
import { Navigation } from "@/components/navigation";
|
|
||||||
import { TaskNotificationMenu } from "@/components/task-notification-menu";
|
import { TaskNotificationMenu } from "@/components/task-notification-menu";
|
||||||
import { UserNav } from "@/components/user-nav";
|
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
import { useChat } from "@/contexts/chat-context";
|
|
||||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
// import { GitHubStarButton } from "@/components/github-star-button"
|
|
||||||
// import { DiscordLink } from "@/components/discord-link"
|
|
||||||
import { useTask } from "@/contexts/task-context";
|
import { useTask } from "@/contexts/task-context";
|
||||||
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
|
||||||
|
import { ChatRenderer } from "./chat-renderer";
|
||||||
|
|
||||||
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { tasks, isMenuOpen, toggleMenu } = useTask();
|
const { isMenuOpen } = useTask();
|
||||||
const { isPanelOpen } = useKnowledgeFilter();
|
const { isPanelOpen } = useKnowledgeFilter();
|
||||||
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
|
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
|
||||||
const {
|
|
||||||
endpoint,
|
const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({
|
||||||
refreshTrigger,
|
|
||||||
refreshConversations,
|
|
||||||
startNewConversation,
|
|
||||||
} = useChat();
|
|
||||||
const { isLoading: isSettingsLoading } = useGetSettingsQuery({
|
|
||||||
enabled: isAuthenticated || isNoAuthMode,
|
enabled: isAuthenticated || isNoAuthMode,
|
||||||
});
|
});
|
||||||
const {
|
const {
|
||||||
|
|
@ -42,40 +28,17 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
isError,
|
isError,
|
||||||
} = useDoclingHealthQuery();
|
} = useDoclingHealthQuery();
|
||||||
|
|
||||||
// Only fetch conversations on chat page
|
|
||||||
const isOnChatPage = pathname === "/" || pathname === "/chat";
|
|
||||||
const { data: conversations = [], isLoading: isConversationsLoading } =
|
|
||||||
useGetConversationsQuery(endpoint, refreshTrigger, {
|
|
||||||
enabled: isOnChatPage && (isAuthenticated || isNoAuthMode),
|
|
||||||
}) as { data: ChatConversation[]; isLoading: boolean };
|
|
||||||
|
|
||||||
const handleNewConversation = () => {
|
|
||||||
refreshConversations();
|
|
||||||
startNewConversation();
|
|
||||||
};
|
|
||||||
|
|
||||||
// List of paths that should not show navigation
|
// List of paths that should not show navigation
|
||||||
const authPaths = ["/login", "/auth/callback", "/onboarding", "/new-onboarding"];
|
const authPaths = ["/login", "/auth/callback"];
|
||||||
const isAuthPage = authPaths.includes(pathname);
|
const isAuthPage = authPaths.includes(pathname);
|
||||||
const isOnKnowledgePage = pathname.startsWith("/knowledge");
|
const isOnKnowledgePage = pathname.startsWith("/knowledge");
|
||||||
|
|
||||||
// List of paths with smaller max-width
|
|
||||||
const smallWidthPaths = ["/settings/connector/new"];
|
|
||||||
const isSmallWidthPath = smallWidthPaths.includes(pathname);
|
|
||||||
|
|
||||||
// Calculate active tasks for the bell icon
|
|
||||||
const activeTasks = tasks.filter(
|
|
||||||
task =>
|
|
||||||
task.status === "pending" ||
|
|
||||||
task.status === "running" ||
|
|
||||||
task.status === "processing"
|
|
||||||
);
|
|
||||||
|
|
||||||
const isUnhealthy = health?.status === "unhealthy" || isError;
|
const isUnhealthy = health?.status === "unhealthy" || isError;
|
||||||
const isBannerVisible = !isHealthLoading && isUnhealthy;
|
const isBannerVisible = !isHealthLoading && isUnhealthy;
|
||||||
|
const isSettingsLoadingOrError = isSettingsLoading || !settings;
|
||||||
|
|
||||||
// Show loading state when backend isn't ready
|
// Show loading state when backend isn't ready
|
||||||
if (isLoading || isSettingsLoading) {
|
if (isLoading || (isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated))) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-background">
|
<div className="min-h-screen flex items-center justify-center bg-background">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|
@ -93,78 +56,20 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
|
||||||
// For all other pages, render with Langflow-styled navigation and task menu
|
// For all other pages, render with Langflow-styled navigation and task menu
|
||||||
return (
|
return (
|
||||||
|
<div className=" h-screen w-screen flex items-center justify-center">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"app-grid-arrangement",
|
"app-grid-arrangement bg-black relative",
|
||||||
isBannerVisible && "banner-visible",
|
isBannerVisible && "banner-visible",
|
||||||
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
|
isPanelOpen && isOnKnowledgePage && !isMenuOpen && "filters-open",
|
||||||
isMenuOpen && "notifications-open"
|
isMenuOpen && "notifications-open",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="w-full [grid-area:banner]">
|
<div className={`w-full z-10 bg-background [grid-area:banner]`}>
|
||||||
<DoclingHealthBanner className="w-full" />
|
<DoclingHealthBanner className="w-full" />
|
||||||
</div>
|
</div>
|
||||||
<header className="header-arrangement bg-background [grid-area:header]">
|
|
||||||
<div className="header-start-display px-[16px]">
|
|
||||||
{/* Logo/Title */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Logo className="fill-primary" width={24} height={22} />
|
|
||||||
<span className="text-lg font-semibold pl-2.5">OpenRAG</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="header-end-division">
|
|
||||||
<div className="justify-end flex items-center">
|
|
||||||
{/* Knowledge Filter Dropdown */}
|
|
||||||
{/* <KnowledgeFilterDropdown
|
|
||||||
selectedFilter={selectedFilter}
|
|
||||||
onFilterSelect={setSelectedFilter}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* GitHub Star Button */}
|
<ChatRenderer settings={settings}>{children}</ChatRenderer>
|
||||||
{/* <GitHubStarButton repo="phact/openrag" /> */}
|
|
||||||
|
|
||||||
{/* Discord Link */}
|
|
||||||
{/* <DiscordLink inviteCode="EqksyE2EX9" /> */}
|
|
||||||
|
|
||||||
{/* Task Notification Bell */}
|
|
||||||
<button
|
|
||||||
onClick={toggleMenu}
|
|
||||||
className="relative h-8 w-8 hover:bg-muted rounded-lg flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Bell size={16} className="text-muted-foreground" />
|
|
||||||
{activeTasks.length > 0 && (
|
|
||||||
<div className="header-notifications" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Separator */}
|
|
||||||
<div className="w-px h-6 bg-border mx-3" />
|
|
||||||
|
|
||||||
<UserNav />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
{/* Sidebar Navigation */}
|
|
||||||
<aside className="bg-background border-r overflow-hidden [grid-area:nav]">
|
|
||||||
<Navigation
|
|
||||||
conversations={conversations}
|
|
||||||
isConversationsLoading={isConversationsLoading}
|
|
||||||
onNewConversation={handleNewConversation}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
{/* Main Content */}
|
|
||||||
<main className="overflow-y-auto [grid-area:main]">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"p-6 h-full container",
|
|
||||||
isSmallWidthPath && "max-w-[850px] ml-0"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
|
|
||||||
{/* Task Notifications Panel */}
|
{/* Task Notifications Panel */}
|
||||||
<aside className="overflow-y-auto overflow-x-hidden [grid-area:notifications]">
|
<aside className="overflow-y-auto overflow-x-hidden [grid-area:notifications]">
|
||||||
|
|
@ -176,5 +81,6 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
|
||||||
{isPanelOpen && <KnowledgeFilterPanel />}
|
{isPanelOpen && <KnowledgeFilterPanel />}
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
|
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
interface ProtectedRouteProps {
|
interface ProtectedRouteProps {
|
||||||
|
|
@ -12,10 +11,6 @@ interface ProtectedRouteProps {
|
||||||
|
|
||||||
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
|
const { isLoading, isAuthenticated, isNoAuthMode } = useAuth();
|
||||||
const { data: settings = {}, isLoading: isSettingsLoading } =
|
|
||||||
useGetSettingsQuery({
|
|
||||||
enabled: isAuthenticated || isNoAuthMode,
|
|
||||||
});
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
|
@ -31,30 +26,22 @@ export function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isLoading && !isSettingsLoading && !isAuthenticated && !isNoAuthMode) {
|
if (!isLoading && !isAuthenticated && !isNoAuthMode) {
|
||||||
// Redirect to login with current path as redirect parameter
|
// Redirect to login with current path as redirect parameter
|
||||||
const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}`;
|
const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}`;
|
||||||
router.push(redirectUrl);
|
router.push(redirectUrl);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isLoading && !isSettingsLoading && !settings.edited) {
|
|
||||||
const updatedOnboarding = process.env.UPDATED_ONBOARDING === "true";
|
|
||||||
router.push(updatedOnboarding ? "/new-onboarding" : "/onboarding");
|
|
||||||
}
|
|
||||||
}, [
|
}, [
|
||||||
isLoading,
|
isLoading,
|
||||||
isSettingsLoading,
|
|
||||||
isAuthenticated,
|
isAuthenticated,
|
||||||
isNoAuthMode,
|
isNoAuthMode,
|
||||||
router,
|
router,
|
||||||
pathname,
|
pathname,
|
||||||
isSettingsLoading,
|
|
||||||
settings.edited,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Show loading state while checking authentication
|
// Show loading state while checking authentication
|
||||||
if (isLoading || isSettingsLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center h-64">
|
<div className="flex items-center justify-center h-64">
|
||||||
<div className="flex flex-col items-center gap-4">
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
|
|
||||||
492
frontend/src/hooks/useChatStreaming.ts
Normal file
492
frontend/src/hooks/useChatStreaming.ts
Normal file
|
|
@ -0,0 +1,492 @@
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import type { FunctionCall, Message, SelectedFilters } from "@/app/chat/types";
|
||||||
|
|
||||||
|
interface UseChatStreamingOptions {
|
||||||
|
endpoint?: string;
|
||||||
|
onComplete?: (message: Message, responseId: string | null) => void;
|
||||||
|
onError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SendMessageOptions {
|
||||||
|
prompt: string;
|
||||||
|
previousResponseId?: string;
|
||||||
|
filters?: SelectedFilters;
|
||||||
|
limit?: number;
|
||||||
|
scoreThreshold?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatStreaming({
|
||||||
|
endpoint = "/api/langflow",
|
||||||
|
onComplete,
|
||||||
|
onError,
|
||||||
|
}: UseChatStreamingOptions = {}) {
|
||||||
|
const [streamingMessage, setStreamingMessage] = useState<Message | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const streamAbortRef = useRef<AbortController | null>(null);
|
||||||
|
const streamIdRef = useRef(0);
|
||||||
|
|
||||||
|
const sendMessage = async ({
|
||||||
|
prompt,
|
||||||
|
previousResponseId,
|
||||||
|
filters,
|
||||||
|
limit = 10,
|
||||||
|
scoreThreshold = 0,
|
||||||
|
}: SendMessageOptions) => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Abort any existing stream before starting a new one
|
||||||
|
if (streamAbortRef.current) {
|
||||||
|
streamAbortRef.current.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
streamAbortRef.current = controller;
|
||||||
|
const thisStreamId = ++streamIdRef.current;
|
||||||
|
|
||||||
|
const requestBody: {
|
||||||
|
prompt: string;
|
||||||
|
stream: boolean;
|
||||||
|
previous_response_id?: string;
|
||||||
|
filters?: SelectedFilters;
|
||||||
|
limit?: number;
|
||||||
|
scoreThreshold?: number;
|
||||||
|
} = {
|
||||||
|
prompt,
|
||||||
|
stream: true,
|
||||||
|
limit,
|
||||||
|
scoreThreshold,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (previousResponseId) {
|
||||||
|
requestBody.previous_response_id = previousResponseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filters) {
|
||||||
|
requestBody.filters = filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
throw new Error("No reader available");
|
||||||
|
}
|
||||||
|
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = "";
|
||||||
|
let currentContent = "";
|
||||||
|
const currentFunctionCalls: FunctionCall[] = [];
|
||||||
|
let newResponseId: string | null = null;
|
||||||
|
|
||||||
|
// Initialize streaming message
|
||||||
|
if (!controller.signal.aborted && thisStreamId === streamIdRef.current) {
|
||||||
|
setStreamingMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: "",
|
||||||
|
timestamp: new Date(),
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (controller.signal.aborted || thisStreamId !== streamIdRef.current)
|
||||||
|
break;
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
|
||||||
|
// Process complete lines (JSON objects)
|
||||||
|
const lines = buffer.split("\n");
|
||||||
|
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) {
|
||||||
|
try {
|
||||||
|
const chunk = JSON.parse(line);
|
||||||
|
|
||||||
|
// Extract response ID if present
|
||||||
|
if (chunk.id) {
|
||||||
|
newResponseId = chunk.id;
|
||||||
|
} else if (chunk.response_id) {
|
||||||
|
newResponseId = chunk.response_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle OpenAI Chat Completions streaming format
|
||||||
|
if (chunk.object === "response.chunk" && chunk.delta) {
|
||||||
|
// Handle function calls in delta
|
||||||
|
if (chunk.delta.function_call) {
|
||||||
|
if (chunk.delta.function_call.name) {
|
||||||
|
const functionCall: FunctionCall = {
|
||||||
|
name: chunk.delta.function_call.name,
|
||||||
|
arguments: undefined,
|
||||||
|
status: "pending",
|
||||||
|
argumentsString:
|
||||||
|
chunk.delta.function_call.arguments || "",
|
||||||
|
};
|
||||||
|
currentFunctionCalls.push(functionCall);
|
||||||
|
} else if (chunk.delta.function_call.arguments) {
|
||||||
|
const lastFunctionCall =
|
||||||
|
currentFunctionCalls[currentFunctionCalls.length - 1];
|
||||||
|
if (lastFunctionCall) {
|
||||||
|
if (!lastFunctionCall.argumentsString) {
|
||||||
|
lastFunctionCall.argumentsString = "";
|
||||||
|
}
|
||||||
|
lastFunctionCall.argumentsString +=
|
||||||
|
chunk.delta.function_call.arguments;
|
||||||
|
|
||||||
|
if (lastFunctionCall.argumentsString.includes("}")) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
lastFunctionCall.argumentsString
|
||||||
|
);
|
||||||
|
lastFunctionCall.arguments = parsed;
|
||||||
|
lastFunctionCall.status = "completed";
|
||||||
|
} catch (e) {
|
||||||
|
// Arguments not yet complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle tool calls in delta
|
||||||
|
else if (
|
||||||
|
chunk.delta.tool_calls &&
|
||||||
|
Array.isArray(chunk.delta.tool_calls)
|
||||||
|
) {
|
||||||
|
for (const toolCall of chunk.delta.tool_calls) {
|
||||||
|
if (toolCall.function) {
|
||||||
|
if (toolCall.function.name) {
|
||||||
|
const functionCall: FunctionCall = {
|
||||||
|
name: toolCall.function.name,
|
||||||
|
arguments: undefined,
|
||||||
|
status: "pending",
|
||||||
|
argumentsString: toolCall.function.arguments || "",
|
||||||
|
};
|
||||||
|
currentFunctionCalls.push(functionCall);
|
||||||
|
} else if (toolCall.function.arguments) {
|
||||||
|
const lastFunctionCall =
|
||||||
|
currentFunctionCalls[
|
||||||
|
currentFunctionCalls.length - 1
|
||||||
|
];
|
||||||
|
if (lastFunctionCall) {
|
||||||
|
if (!lastFunctionCall.argumentsString) {
|
||||||
|
lastFunctionCall.argumentsString = "";
|
||||||
|
}
|
||||||
|
lastFunctionCall.argumentsString +=
|
||||||
|
toolCall.function.arguments;
|
||||||
|
|
||||||
|
if (
|
||||||
|
lastFunctionCall.argumentsString.includes("}")
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(
|
||||||
|
lastFunctionCall.argumentsString
|
||||||
|
);
|
||||||
|
lastFunctionCall.arguments = parsed;
|
||||||
|
lastFunctionCall.status = "completed";
|
||||||
|
} catch (e) {
|
||||||
|
// Arguments not yet complete
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle content/text in delta
|
||||||
|
else if (chunk.delta.content) {
|
||||||
|
currentContent += chunk.delta.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle finish reason
|
||||||
|
if (chunk.delta.finish_reason) {
|
||||||
|
currentFunctionCalls.forEach((fc) => {
|
||||||
|
if (fc.status === "pending" && fc.argumentsString) {
|
||||||
|
try {
|
||||||
|
fc.arguments = JSON.parse(fc.argumentsString);
|
||||||
|
fc.status = "completed";
|
||||||
|
} catch (e) {
|
||||||
|
fc.arguments = { raw: fc.argumentsString };
|
||||||
|
fc.status = "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle Realtime API format - function call added
|
||||||
|
else if (
|
||||||
|
chunk.type === "response.output_item.added" &&
|
||||||
|
chunk.item?.type === "function_call"
|
||||||
|
) {
|
||||||
|
let existing = currentFunctionCalls.find(
|
||||||
|
(fc) => fc.id === chunk.item.id
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
existing = [...currentFunctionCalls]
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(fc) =>
|
||||||
|
fc.status === "pending" &&
|
||||||
|
!fc.id &&
|
||||||
|
fc.name === (chunk.item.tool_name || chunk.item.name)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.id = chunk.item.id;
|
||||||
|
existing.type = chunk.item.type;
|
||||||
|
existing.name =
|
||||||
|
chunk.item.tool_name || chunk.item.name || existing.name;
|
||||||
|
existing.arguments =
|
||||||
|
chunk.item.inputs || existing.arguments;
|
||||||
|
} else {
|
||||||
|
const functionCall: FunctionCall = {
|
||||||
|
name:
|
||||||
|
chunk.item.tool_name || chunk.item.name || "unknown",
|
||||||
|
arguments: chunk.item.inputs || undefined,
|
||||||
|
status: "pending",
|
||||||
|
argumentsString: "",
|
||||||
|
id: chunk.item.id,
|
||||||
|
type: chunk.item.type,
|
||||||
|
};
|
||||||
|
currentFunctionCalls.push(functionCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle Realtime API format - tool call added
|
||||||
|
else if (
|
||||||
|
chunk.type === "response.output_item.added" &&
|
||||||
|
chunk.item?.type?.includes("_call") &&
|
||||||
|
chunk.item?.type !== "function_call"
|
||||||
|
) {
|
||||||
|
let existing = currentFunctionCalls.find(
|
||||||
|
(fc) => fc.id === chunk.item.id
|
||||||
|
);
|
||||||
|
if (!existing) {
|
||||||
|
existing = [...currentFunctionCalls]
|
||||||
|
.reverse()
|
||||||
|
.find(
|
||||||
|
(fc) =>
|
||||||
|
fc.status === "pending" &&
|
||||||
|
!fc.id &&
|
||||||
|
fc.name ===
|
||||||
|
(chunk.item.tool_name ||
|
||||||
|
chunk.item.name ||
|
||||||
|
chunk.item.type)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.id = chunk.item.id;
|
||||||
|
existing.type = chunk.item.type;
|
||||||
|
existing.name =
|
||||||
|
chunk.item.tool_name ||
|
||||||
|
chunk.item.name ||
|
||||||
|
chunk.item.type ||
|
||||||
|
existing.name;
|
||||||
|
existing.arguments =
|
||||||
|
chunk.item.inputs || existing.arguments;
|
||||||
|
} else {
|
||||||
|
const functionCall = {
|
||||||
|
name:
|
||||||
|
chunk.item.tool_name ||
|
||||||
|
chunk.item.name ||
|
||||||
|
chunk.item.type ||
|
||||||
|
"unknown",
|
||||||
|
arguments: chunk.item.inputs || {},
|
||||||
|
status: "pending" as const,
|
||||||
|
id: chunk.item.id,
|
||||||
|
type: chunk.item.type,
|
||||||
|
};
|
||||||
|
currentFunctionCalls.push(functionCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle function call done
|
||||||
|
else if (
|
||||||
|
chunk.type === "response.output_item.done" &&
|
||||||
|
chunk.item?.type === "function_call"
|
||||||
|
) {
|
||||||
|
const functionCall = currentFunctionCalls.find(
|
||||||
|
(fc) =>
|
||||||
|
fc.id === chunk.item.id ||
|
||||||
|
fc.name === chunk.item.tool_name ||
|
||||||
|
fc.name === chunk.item.name
|
||||||
|
);
|
||||||
|
|
||||||
|
if (functionCall) {
|
||||||
|
functionCall.status =
|
||||||
|
chunk.item.status === "completed" ? "completed" : "error";
|
||||||
|
functionCall.id = chunk.item.id;
|
||||||
|
functionCall.type = chunk.item.type;
|
||||||
|
functionCall.name =
|
||||||
|
chunk.item.tool_name ||
|
||||||
|
chunk.item.name ||
|
||||||
|
functionCall.name;
|
||||||
|
functionCall.arguments =
|
||||||
|
chunk.item.inputs || functionCall.arguments;
|
||||||
|
|
||||||
|
if (chunk.item.results) {
|
||||||
|
functionCall.result = chunk.item.results;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle tool call done with results
|
||||||
|
else if (
|
||||||
|
chunk.type === "response.output_item.done" &&
|
||||||
|
chunk.item?.type?.includes("_call") &&
|
||||||
|
chunk.item?.type !== "function_call"
|
||||||
|
) {
|
||||||
|
const functionCall = currentFunctionCalls.find(
|
||||||
|
(fc) =>
|
||||||
|
fc.id === chunk.item.id ||
|
||||||
|
fc.name === chunk.item.tool_name ||
|
||||||
|
fc.name === chunk.item.name ||
|
||||||
|
fc.name === chunk.item.type ||
|
||||||
|
fc.name.includes(chunk.item.type.replace("_call", "")) ||
|
||||||
|
chunk.item.type.includes(fc.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (functionCall) {
|
||||||
|
functionCall.arguments =
|
||||||
|
chunk.item.inputs || functionCall.arguments;
|
||||||
|
functionCall.status =
|
||||||
|
chunk.item.status === "completed" ? "completed" : "error";
|
||||||
|
functionCall.id = chunk.item.id;
|
||||||
|
functionCall.type = chunk.item.type;
|
||||||
|
|
||||||
|
if (chunk.item.results) {
|
||||||
|
functionCall.result = chunk.item.results;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const newFunctionCall = {
|
||||||
|
name:
|
||||||
|
chunk.item.tool_name ||
|
||||||
|
chunk.item.name ||
|
||||||
|
chunk.item.type ||
|
||||||
|
"unknown",
|
||||||
|
arguments: chunk.item.inputs || {},
|
||||||
|
status: "completed" as const,
|
||||||
|
id: chunk.item.id,
|
||||||
|
type: chunk.item.type,
|
||||||
|
result: chunk.item.results,
|
||||||
|
};
|
||||||
|
currentFunctionCalls.push(newFunctionCall);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Handle text output streaming (Realtime API)
|
||||||
|
else if (chunk.type === "response.output_text.delta") {
|
||||||
|
currentContent += chunk.delta || "";
|
||||||
|
}
|
||||||
|
// Handle OpenRAG backend format
|
||||||
|
else if (chunk.output_text) {
|
||||||
|
currentContent += chunk.output_text;
|
||||||
|
} else if (chunk.delta) {
|
||||||
|
if (typeof chunk.delta === "string") {
|
||||||
|
currentContent += chunk.delta;
|
||||||
|
} else if (typeof chunk.delta === "object") {
|
||||||
|
if (chunk.delta.content) {
|
||||||
|
currentContent += chunk.delta.content;
|
||||||
|
} else if (chunk.delta.text) {
|
||||||
|
currentContent += chunk.delta.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update streaming message in real-time
|
||||||
|
if (
|
||||||
|
!controller.signal.aborted &&
|
||||||
|
thisStreamId === streamIdRef.current
|
||||||
|
) {
|
||||||
|
setStreamingMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: currentContent,
|
||||||
|
functionCalls:
|
||||||
|
currentFunctionCalls.length > 0
|
||||||
|
? [...currentFunctionCalls]
|
||||||
|
: undefined,
|
||||||
|
timestamp: new Date(),
|
||||||
|
isStreaming: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn("Failed to parse chunk:", line, parseError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finalize the message
|
||||||
|
const finalMessage: Message = {
|
||||||
|
role: "assistant",
|
||||||
|
content: currentContent,
|
||||||
|
functionCalls:
|
||||||
|
currentFunctionCalls.length > 0 ? currentFunctionCalls : undefined,
|
||||||
|
timestamp: new Date(),
|
||||||
|
isStreaming: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!controller.signal.aborted && thisStreamId === streamIdRef.current) {
|
||||||
|
// Clear streaming message and call onComplete with final message
|
||||||
|
setStreamingMessage(null);
|
||||||
|
onComplete?.(finalMessage, newResponseId);
|
||||||
|
return finalMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
// If stream was aborted, don't handle as error
|
||||||
|
if (streamAbortRef.current?.signal.aborted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error("SSE Stream error:", error);
|
||||||
|
setStreamingMessage(null);
|
||||||
|
onError?.(error as Error);
|
||||||
|
|
||||||
|
const errorMessage: Message = {
|
||||||
|
role: "assistant",
|
||||||
|
content:
|
||||||
|
"Sorry, I couldn't connect to the chat service. Please try again.",
|
||||||
|
timestamp: new Date(),
|
||||||
|
isStreaming: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return errorMessage;
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const abortStream = () => {
|
||||||
|
if (streamAbortRef.current) {
|
||||||
|
streamAbortRef.current.abort();
|
||||||
|
}
|
||||||
|
setStreamingMessage(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
streamingMessage,
|
||||||
|
isLoading,
|
||||||
|
sendMessage,
|
||||||
|
abortStream,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
*/
|
*/
|
||||||
export const DEFAULT_AGENT_SETTINGS = {
|
export const DEFAULT_AGENT_SETTINGS = {
|
||||||
llm_model: "gpt-4o-mini",
|
llm_model: "gpt-4o-mini",
|
||||||
system_prompt: "You are a helpful assistant that can use tools to answer questions and perform tasks."
|
system_prompt: "You are a helpful assistant that can use tools to answer questions and perform tasks. You are part of OpenRAG, an assistant that analyzes documents and provides informations about them. When asked about what is OpenRAG, answer the following:\n\n\"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.langflow.org/)\n\n**OpenSearch** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://opensearch.org/)\n\n**Docling** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.docling.ai/)\""
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -23,3 +23,16 @@ export const DEFAULT_KNOWLEDGE_SETTINGS = {
|
||||||
export const UI_CONSTANTS = {
|
export const UI_CONSTANTS = {
|
||||||
MAX_SYSTEM_PROMPT_CHARS: 2000,
|
MAX_SYSTEM_PROMPT_CHARS: 2000,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
export const ANIMATION_DURATION = 0.4;
|
||||||
|
export const SIDEBAR_WIDTH = 280;
|
||||||
|
export const HEADER_HEIGHT = 54;
|
||||||
|
export const TOTAL_ONBOARDING_STEPS = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Local Storage Keys
|
||||||
|
*/
|
||||||
|
export const ONBOARDING_STEP_KEY = "onboarding_current_step";
|
||||||
|
|
||||||
|
export const FILES_REGEX =
|
||||||
|
/(?<=I'm uploading a document called ['"])[^'"]+\.[^.]+(?=['"]\. Here is its content:)/;
|
||||||
|
|
@ -72,6 +72,14 @@ const config = {
|
||||||
height: "0",
|
height: "0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
shimmer: {
|
||||||
|
"0%": {
|
||||||
|
backgroundPosition: "200% 0",
|
||||||
|
},
|
||||||
|
"100%": {
|
||||||
|
backgroundPosition: "-200% 0",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
animation: {
|
animation: {
|
||||||
overlayShow: "overlayShow 400ms cubic-bezier(0.16, 1, 0.3, 1)",
|
overlayShow: "overlayShow 400ms cubic-bezier(0.16, 1, 0.3, 1)",
|
||||||
|
|
@ -79,6 +87,7 @@ const config = {
|
||||||
wiggle: "wiggle 150ms ease-in-out 1",
|
wiggle: "wiggle 150ms ease-in-out 1",
|
||||||
"accordion-down": "accordion-down 0.2s ease-out",
|
"accordion-down": "accordion-down 0.2s ease-out",
|
||||||
"accordion-up": "accordion-up 0.2s ease-out",
|
"accordion-up": "accordion-up 0.2s ease-out",
|
||||||
|
shimmer: "shimmer 3s ease-in-out infinite",
|
||||||
},
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ def get_conversation_thread(user_id: str, previous_response_id: str = None):
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "system",
|
"role": "system",
|
||||||
"content": "You are a helpful assistant. Always use the search_tools to answer questions.",
|
"content": "You are a helpful assistant that can use tools to answer questions and perform tasks. You are part of OpenRAG, an assistant that analyzes documents and provides informations about them. When asked about what is OpenRAG, answer the following:\n\n\"OpenRAG is an open-source package for building agentic RAG systems. It supports integration with a wide range of orchestration tools, vector databases, and LLM providers. OpenRAG connects and amplifies three popular, proven open-source projects into one powerful platform:\n\n**Langflow** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.langflow.org/)\n\n**OpenSearch** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://opensearch.org/)\n\n**Docling** – Langflow is a powerful tool to build and deploy AI agents and MCP servers [Read more](https://www.docling.ai/)\"",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"previous_response_id": previous_response_id, # Parent response_id for branching
|
"previous_response_id": previous_response_id, # Parent response_id for branching
|
||||||
|
|
|
||||||
|
|
@ -424,13 +424,10 @@ async def onboarding(request, flows_service):
|
||||||
# Get current configuration
|
# Get current configuration
|
||||||
current_config = get_openrag_config()
|
current_config = get_openrag_config()
|
||||||
|
|
||||||
# Check if config is NOT marked as edited (only allow onboarding if not yet configured)
|
# Warn if config was already edited (onboarding being re-run)
|
||||||
if current_config.edited:
|
if current_config.edited:
|
||||||
return JSONResponse(
|
logger.warning(
|
||||||
{
|
"Onboarding is being run although configuration was already edited before"
|
||||||
"error": "Configuration has already been edited. Use /settings endpoint for updates."
|
|
||||||
},
|
|
||||||
status_code=403,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parse request body
|
# Parse request body
|
||||||
|
|
|
||||||
|
|
@ -100,12 +100,11 @@ async def upload_context(
|
||||||
previous_response_id = form.get("previous_response_id")
|
previous_response_id = form.get("previous_response_id")
|
||||||
endpoint = form.get("endpoint", "langflow")
|
endpoint = form.get("endpoint", "langflow")
|
||||||
|
|
||||||
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
|
||||||
|
|
||||||
# Get user info from request state (set by auth middleware)
|
# Get user info from request state (set by auth middleware)
|
||||||
user = request.state.user
|
user = request.state.user
|
||||||
user_id = user.user_id if user else None
|
user_id = user.user_id if user else None
|
||||||
|
|
||||||
|
jwt_token = session_manager.get_effective_jwt_token(user_id, request.state.jwt_token)
|
||||||
# Process document and extract content
|
# Process document and extract content
|
||||||
doc_result = await document_service.process_upload_context(upload_file, filename)
|
doc_result = await document_service.process_upload_context(upload_file, filename)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue