reorganize folder structure

This commit is contained in:
Cole Goldsmith 2025-11-14 15:32:40 -06:00
parent 173df0be99
commit 2d0838d5a8
160 changed files with 9816 additions and 10346 deletions

View file

@ -0,0 +1,259 @@
"use client";
import { ArrowLeft, CheckCircle, Loader2, XCircle } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import AnimatedProcessingIcon from "@/components/icons/animated-processing-icon";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAuth } from "@/contexts/auth-context";
function AuthCallbackContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshAuth } = useAuth();
const [status, setStatus] = useState<"processing" | "success" | "error">(
"processing",
);
const [error, setError] = useState<string | null>(null);
const [purpose, setPurpose] = useState<string>("app_auth");
useEffect(() => {
const code = searchParams.get("code");
const callbackKey = `callback_processed_${code}`;
// Prevent double execution across component remounts
if (sessionStorage.getItem(callbackKey)) {
return;
}
sessionStorage.setItem(callbackKey, "true");
const handleCallback = async () => {
try {
// Get parameters from URL
const state = searchParams.get("state");
const errorParam = searchParams.get("error");
// Get stored auth info
const connectorId = localStorage.getItem("connecting_connector_id");
const storedConnectorType = localStorage.getItem(
"connecting_connector_type",
);
const authPurpose = localStorage.getItem("auth_purpose");
// Determine purpose - default to app_auth for login, data_source for connectors
const detectedPurpose =
authPurpose ||
(storedConnectorType?.includes("drive") ? "data_source" : "app_auth");
setPurpose(detectedPurpose);
// Debug logging
console.log("OAuth Callback Debug:", {
urlParams: { code: !!code, state: !!state, error: errorParam },
localStorage: { connectorId, storedConnectorType, authPurpose },
detectedPurpose,
fullUrl: window.location.href,
});
// Use state parameter as connection_id if localStorage is missing
const finalConnectorId = connectorId || state;
if (errorParam) {
throw new Error(`OAuth error: ${errorParam}`);
}
if (!code || !state || !finalConnectorId) {
console.error("Missing OAuth callback parameters:", {
code: !!code,
state: !!state,
finalConnectorId: !!finalConnectorId,
});
throw new Error("Missing required parameters for OAuth callback");
}
// Send callback data to backend
const response = await fetch("/api/auth/callback", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
connection_id: finalConnectorId,
authorization_code: code,
state: state,
}),
});
const result = await response.json();
if (response.ok) {
setStatus("success");
if (result.purpose === "app_auth" || detectedPurpose === "app_auth") {
// App authentication - refresh auth context and redirect to home/original page
await refreshAuth();
// Get redirect URL from login page
const redirectTo = searchParams.get("redirect") || "/chat";
// Clean up localStorage
localStorage.removeItem("connecting_connector_id");
localStorage.removeItem("connecting_connector_type");
localStorage.removeItem("auth_purpose");
// Redirect to the original page or home
setTimeout(() => {
router.push(redirectTo);
}, 2000);
} else {
// Connector authentication - redirect to connectors page
// Clean up localStorage
localStorage.removeItem("connecting_connector_id");
localStorage.removeItem("connecting_connector_type");
localStorage.removeItem("auth_purpose");
// Redirect to connectors page with success indicator
setTimeout(() => {
router.push("/connectors?oauth_success=true");
}, 2000);
}
} else {
throw new Error(result.error || "Authentication failed");
}
} catch (err) {
console.error("OAuth callback error:", err);
setError(err instanceof Error ? err.message : "Unknown error occurred");
setStatus("error");
// Clean up localStorage on error too
localStorage.removeItem("connecting_connector_id");
localStorage.removeItem("connecting_connector_type");
localStorage.removeItem("auth_purpose");
}
};
handleCallback();
}, [searchParams, router, refreshAuth]);
// Dynamic UI content based on purpose
const isAppAuth = purpose === "app_auth";
const getTitle = () => {
if (status === "processing") {
return isAppAuth ? "Signing you in..." : "Connecting...";
}
if (status === "success") {
return isAppAuth ? "Welcome to OpenRAG!" : "Connection Successful!";
}
if (status === "error") {
return isAppAuth ? "Sign In Failed" : "Connection Failed";
}
};
const getDescription = () => {
if (status === "processing") {
return isAppAuth
? "Please wait while we complete your sign in..."
: "Please wait while we complete the connection...";
}
if (status === "success") {
return "You will be redirected shortly.";
}
if (status === "error") {
return isAppAuth
? "There was an issue signing you in."
: "There was an issue with the connection.";
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-card rounded-lg m-4">
<Card className="w-full max-w-md bg-card rounded-lg m-4">
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
{status === "processing" && (
<>
<AnimatedProcessingIcon className="h-5 w-5 text-current" />
{getTitle()}
</>
)}
{status === "success" && (
<>
<CheckCircle className="h-5 w-5 text-green-500" />
{getTitle()}
</>
)}
{status === "error" && (
<>
<XCircle className="h-5 w-5 text-red-500" />
{getTitle()}
</>
)}
</CardTitle>
<CardDescription>{getDescription()}</CardDescription>
</CardHeader>
<CardContent>
{status === "error" && (
<div className="space-y-4">
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
<p className="text-sm text-red-600">{error}</p>
</div>
<Button
onClick={() =>
router.push(isAppAuth ? "/login" : "/connectors")
}
variant="outline"
className="w-full"
>
<ArrowLeft className="h-4 w-4 mr-2" />
{isAppAuth ? "Back to Login" : "Back to Connectors"}
</Button>
</div>
)}
{status === "success" && (
<div className="text-center">
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-sm text-green-600">
{isAppAuth
? "Redirecting you to the app..."
: "Redirecting to connectors..."}
</p>
</div>
</div>
)}
</CardContent>
</Card>
</div>
);
}
export default function AuthCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<CardTitle className="flex items-center justify-center gap-2">
<Loader2 className="h-5 w-5 animate-spin" />
Loading...
</CardTitle>
<CardDescription>
Please wait while we process your request...
</CardDescription>
</CardHeader>
</Card>
</div>
}
>
<AuthCallbackContent />
</Suspense>
);
}

View file

@ -0,0 +1,95 @@
import { GitBranch } from "lucide-react";
import { motion } from "motion/react";
import DogIcon from "@/components/icons/dog-icon";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { cn } from "@/lib/utils";
import type { FunctionCall } from "../_types/types";
import { FunctionCalls } from "./function-calls";
import { Message } from "./message";
interface AssistantMessageProps {
content: string;
functionCalls?: FunctionCall[];
messageIndex?: number;
expandedFunctionCalls: Set<string>;
onToggle: (functionCallId: string) => void;
isStreaming?: boolean;
showForkButton?: boolean;
onFork?: (e: React.MouseEvent) => void;
isCompleted?: boolean;
isInactive?: boolean;
animate?: boolean;
delay?: number;
}
export function AssistantMessage({
content,
functionCalls = [],
messageIndex,
expandedFunctionCalls,
onToggle,
isStreaming = false,
showForkButton = false,
onFork,
isCompleted = false,
isInactive = false,
animate = true,
delay = 0.2,
}: AssistantMessageProps) {
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
icon={
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 transition-colors duration-300"
disabled={isCompleted || isInactive}
/>
</div>
}
actions={
showForkButton && onFork ? (
<button
type="button"
onClick={onFork}
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"
>
<GitBranch className="h-3 w-3" />
</button>
) : undefined
}
>
<FunctionCalls
functionCalls={functionCalls}
messageIndex={messageIndex}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={onToggle}
/>
<div className="relative">
<MarkdownRenderer
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>
</motion.div>
);
}

View file

@ -0,0 +1,339 @@
import { ArrowRight, Check, Funnel, Loader2, Plus } from "lucide-react";
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
import TextareaAutosize from "react-textarea-autosize";
import type { FilterColor } from "@/components/filter-icon-popover";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverAnchor,
PopoverContent,
} from "@/components/ui/popover";
import type { KnowledgeFilterData } from "../_types/types";
import { FilePreview } from "./file-preview";
import { SelectedKnowledgeFilter } from "./selected-knowledge-filter";
export interface ChatInputHandle {
focusInput: () => void;
clickFileInput: () => void;
}
interface ChatInputProps {
input: string;
loading: boolean;
isUploading: boolean;
selectedFilter: KnowledgeFilterData | null;
isFilterDropdownOpen: boolean;
availableFilters: KnowledgeFilterData[];
filterSearchTerm: string;
selectedFilterIndex: number;
anchorPosition: { x: number; y: number } | null;
parsedFilterData: { color?: FilterColor } | null;
uploadedFile: File | null;
onSubmit: (e: React.FormEvent) => void;
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
onAtClick: () => void;
onFilePickerClick: () => void;
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
setIsFilterHighlighted: (highlighted: boolean) => void;
setIsFilterDropdownOpen: (open: boolean) => void;
onFileSelected: (file: File | null) => void;
}
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
(
{
input,
loading,
isUploading,
selectedFilter,
isFilterDropdownOpen,
availableFilters,
filterSearchTerm,
selectedFilterIndex,
anchorPosition,
parsedFilterData,
uploadedFile,
onSubmit,
onChange,
onKeyDown,
onFilterSelect,
onAtClick,
onFilePickerClick,
setSelectedFilter,
setIsFilterHighlighted,
setIsFilterDropdownOpen,
onFileSelected,
},
ref,
) => {
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const [textareaHeight, setTextareaHeight] = useState(0);
useImperativeHandle(ref, () => ({
focusInput: () => {
inputRef.current?.focus();
},
clickFileInput: () => {
fileInputRef.current?.click();
},
}));
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (files && files.length > 0) {
onFileSelected(files[0]);
} else {
onFileSelected(null);
}
};
return (
<div className="w-full">
<form onSubmit={onSubmit} className="relative">
{/* Outer container - flex-col to stack file preview above input */}
<div className="flex flex-col w-full gap-2 rounded-xl border border-input hover:[&:not(:focus-within)]:border-muted-foreground focus-within:border-foreground p-2 transition-colors">
{/* File Preview Section - Always above */}
{uploadedFile && (
<FilePreview
uploadedFile={uploadedFile}
onClear={() => {
onFileSelected(null);
}}
/>
)}
{/* Main Input Container - flex-row or flex-col based on textarea height */}
<div
className={`relative flex w-full gap-2 ${
textareaHeight > 40 ? "flex-col" : "flex-row items-center"
}`}
>
{/* Filter + Textarea Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "w-full" : "flex-1"}`}
>
{textareaHeight <= 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<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
className="relative flex-1"
style={{ height: `${textareaHeight}px` }}
>
<TextareaAutosize
ref={inputRef}
value={input}
onChange={onChange}
onKeyDown={onKeyDown}
onHeightChange={(height) => setTextareaHeight(height)}
maxRows={7}
autoComplete="off"
minRows={1}
placeholder="Ask a question..."
disabled={loading}
className={`w-full text-sm bg-transparent focus-visible:outline-none resize-none`}
rows={1}
/>
</div>
</div>
{/* Action Buttons Section */}
<div
className={`flex items-center gap-2 ${textareaHeight > 40 ? "justify-between w-full" : ""}`}
>
{textareaHeight > 40 &&
(selectedFilter ? (
<SelectedKnowledgeFilter
selectedFilter={selectedFilter}
parsedFilterData={parsedFilterData}
onClear={() => {
setSelectedFilter(null);
setIsFilterHighlighted(false);
}}
/>
) : (
<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 className="flex items-center gap-2">
<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() && !uploadedFile) || 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>
</div>
</div>
<input
ref={fileInputRef}
type="file"
onChange={handleFilePickerChange}
className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/>
<Popover
open={isFilterDropdownOpen}
onOpenChange={(open) => {
setIsFilterDropdownOpen(open);
}}
>
{anchorPosition && (
<PopoverAnchor
asChild
style={{
position: "fixed",
left: anchorPosition.x,
top: anchorPosition.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
>
<div />
</PopoverAnchor>
)}
<PopoverContent
className="w-64 p-2"
side="top"
align="start"
sideOffset={6}
alignOffset={-18}
onOpenAutoFocus={(e) => {
// Prevent auto focus on the popover content
e.preventDefault();
// Keep focus on the input
}}
>
<div className="space-y-1">
{filterSearchTerm && (
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
Searching: @{filterSearchTerm}
</div>
)}
{availableFilters.length === 0 ? (
<div className="px-2 py-3 text-sm text-muted-foreground">
No knowledge filters available
</div>
) : (
<>
{!filterSearchTerm && (
<button
type="button"
onClick={() => onFilterSelect(null)}
className={`w-full text-left px-2 py-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
selectedFilterIndex === -1 ? "bg-muted/50" : ""
}`}
>
<span>No knowledge filter</span>
{!selectedFilter && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
)}
{availableFilters
.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
)
.map((filter, index) => (
<button
key={filter.id}
type="button"
onClick={() => onFilterSelect(filter)}
className={`w-full overflow-hidden text-left px-2 py-2 gap-2 text-sm rounded hover:bg-muted/50 flex items-center justify-between ${
index === selectedFilterIndex ? "bg-muted/50" : ""
}`}
>
<div className="overflow-hidden">
<div className="font-medium truncate">
{filter.name}
</div>
{filter.description && (
<div className="text-xs text-muted-foreground truncate">
{filter.description}
</div>
)}
</div>
{selectedFilter?.id === filter.id && (
<Check className="h-4 w-4 shrink-0" />
)}
</button>
))}
{availableFilters.filter((filter) =>
filter.name
.toLowerCase()
.includes(filterSearchTerm.toLowerCase()),
).length === 0 &&
filterSearchTerm && (
<div className="px-2 py-3 text-sm text-muted-foreground">
No filters match &quot;{filterSearchTerm}&quot;
</div>
)}
</>
)}
</div>
</PopoverContent>
</Popover>
</form>
</div>
);
},
);
ChatInput.displayName = "ChatInput";

View file

@ -0,0 +1,237 @@
import { ChevronDown, ChevronRight, Settings } from "lucide-react";
import type { FunctionCall } from "../_types/types";
interface FunctionCallsProps {
functionCalls: FunctionCall[];
messageIndex?: number;
expandedFunctionCalls: Set<string>;
onToggle: (functionCallId: string) => void;
}
export function FunctionCalls({
functionCalls,
messageIndex,
expandedFunctionCalls,
onToggle,
}: FunctionCallsProps) {
if (!functionCalls || functionCalls.length === 0) return null;
return (
<div className="mb-3 space-y-2">
{functionCalls.map((fc, index) => {
const functionCallId = `${messageIndex || "streaming"}-${index}`;
const isExpanded = expandedFunctionCalls.has(functionCallId);
// Determine display name - show both name and type if available
const displayName =
fc.type && fc.type !== fc.name ? `${fc.name} (${fc.type})` : fc.name;
return (
<div
key={index}
className="rounded-lg bg-blue-500/10 border border-blue-500/20 p-3"
>
<div
className="flex items-center gap-2 cursor-pointer hover:bg-blue-500/5 -m-3 p-3 rounded-lg transition-colors"
onClick={() => onToggle(functionCallId)}
>
<Settings className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-blue-400 flex-1">
Function Call: {displayName}
</span>
{fc.id && (
<span className="text-xs text-blue-300/70 font-mono">
{fc.id.substring(0, 8)}...
</span>
)}
<div
className={`px-2 py-1 rounded text-xs font-medium ${
fc.status === "completed"
? "bg-green-500/20 text-green-400"
: fc.status === "error"
? "bg-red-500/20 text-red-400"
: "bg-yellow-500/20 text-yellow-400"
}`}
>
{fc.status}
</div>
{isExpanded ? (
<ChevronDown className="h-4 w-4 text-blue-400" />
) : (
<ChevronRight className="h-4 w-4 text-blue-400" />
)}
</div>
{isExpanded && (
<div className="mt-3 pt-3 border-t border-blue-500/20">
{/* Show type information if available */}
{fc.type && (
<div className="text-xs text-muted-foreground mb-3">
<span className="font-medium">Type:</span>
<span className="ml-2 px-2 py-1 bg-muted/30 rounded font-mono">
{fc.type}
</span>
</div>
)}
{/* Show ID if available */}
{fc.id && (
<div className="text-xs text-muted-foreground mb-3">
<span className="font-medium">ID:</span>
<span className="ml-2 px-2 py-1 bg-muted/30 rounded font-mono">
{fc.id}
</span>
</div>
)}
{/* Show arguments - either completed or streaming */}
{(fc.arguments || fc.argumentsString) && (
<div className="text-xs text-muted-foreground mb-3">
<span className="font-medium">Arguments:</span>
<pre className="mt-1 p-2 bg-muted/30 rounded text-xs overflow-x-auto">
{fc.arguments
? JSON.stringify(fc.arguments, null, 2)
: fc.argumentsString || "..."}
</pre>
</div>
)}
{fc.result && (
<div className="text-xs text-muted-foreground">
<span className="font-medium">Result:</span>
{Array.isArray(fc.result) ? (
<div className="mt-1 space-y-2">
{(() => {
// Handle different result formats
let resultsToRender = fc.result;
// Check if this is function_call format with nested results
// Function call format: results = [{ results: [...] }]
// Tool call format: results = [{ text_key: ..., data: {...} }]
if (
fc.result.length > 0 &&
fc.result[0]?.results &&
Array.isArray(fc.result[0].results) &&
!fc.result[0].text_key
) {
resultsToRender = fc.result[0].results;
}
type ToolResultItem = {
text_key?: string;
data?: { file_path?: string; text?: string };
filename?: string;
page?: number;
score?: number;
source_url?: string | null;
text?: string;
};
const items =
resultsToRender as unknown as ToolResultItem[];
return items.map((result, idx: number) => (
<div
key={idx}
className="p-2 bg-muted/30 rounded border border-muted/50"
>
{/* Handle tool_call format (file_path in data) */}
{result.data?.file_path && (
<div className="font-medium text-blue-400 mb-1 text-xs">
📄 {result.data.file_path || "Unknown file"}
</div>
)}
{/* Handle function_call format (filename directly) */}
{result.filename && !result.data?.file_path && (
<div className="font-medium text-blue-400 mb-1 text-xs">
📄 {result.filename}
{result.page && ` (page ${result.page})`}
{result.score && (
<span className="ml-2 text-xs text-muted-foreground">
Score: {result.score.toFixed(3)}
</span>
)}
</div>
)}
{/* Handle tool_call text format */}
{result.data?.text && (
<div className="text-xs text-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
{result.data.text.length > 300
? result.data.text.substring(0, 300) + "..."
: result.data.text}
</div>
)}
{/* Handle function_call text format */}
{result.text && !result.data?.text && (
<div className="text-xs text-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
{result.text.length > 300
? result.text.substring(0, 300) + "..."
: result.text}
</div>
)}
{/* Show additional metadata for function_call format */}
{result.source_url && (
<div className="text-xs text-muted-foreground mt-1">
<a
href={result.source_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
Source URL
</a>
</div>
)}
{result.text_key && (
<div className="text-xs text-muted-foreground mt-1">
Key: {result.text_key}
</div>
)}
</div>
));
})()}
<div className="text-xs text-muted-foreground">
Found {(() => {
let resultsToCount = fc.result;
if (
fc.result.length > 0 &&
fc.result[0]?.results &&
Array.isArray(fc.result[0].results) &&
!fc.result[0].text_key
) {
resultsToCount = fc.result[0].results;
}
return resultsToCount.length;
})()} result
{(() => {
let resultsToCount = fc.result;
if (
fc.result.length > 0 &&
fc.result[0]?.results &&
Array.isArray(fc.result[0].results) &&
!fc.result[0].text_key
) {
resultsToCount = fc.result[0].results;
}
return resultsToCount.length !== 1 ? "s" : "";
})()}
</div>
</div>
) : (
<pre className="mt-1 p-2 bg-muted/30 rounded text-xs overflow-x-auto">
{JSON.stringify(fc.result, null, 2)}
</pre>
)}
</div>
)}
</div>
)}
</div>
);
})}
</div>
);
}

View file

@ -0,0 +1,33 @@
import { X } from "lucide-react";
import type { FilterColor } from "@/components/filter-icon-popover";
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
import type { KnowledgeFilterData } from "../_types/types";
interface SelectedKnowledgeFilterProps {
selectedFilter: KnowledgeFilterData;
parsedFilterData: { color?: FilterColor } | null;
onClear: () => void;
}
export const SelectedKnowledgeFilter = ({
selectedFilter,
parsedFilterData,
onClear,
}: SelectedKnowledgeFilterProps) => {
return (
<span
className={`inline-flex items-center p-1 rounded-sm text-xs font-medium transition-colors ${
filterAccentClasses[parsedFilterData?.color || "zinc"]
}`}
>
{selectedFilter.name}
<button
type="button"
onClick={onClear}
className="ml-0.5 rounded-full p-0.5"
>
<X className="h-4 w-4" />
</button>
</span>
);
};

1367
frontend/app/chat/page.tsx Normal file

File diff suppressed because it is too large Load diff

View file

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

Before

Width:  |  Height:  |  Size: 838 B

After

Width:  |  Height:  |  Size: 838 B

View file

@ -0,0 +1,423 @@
"use client";
import {
type CheckboxSelectionCallbackParams,
type ColDef,
type GetRowIdParams,
themeQuartz,
type ValueFormatterParams,
} from "ag-grid-community";
import { AgGridReact, type CustomCellRendererProps } from "ag-grid-react";
import { Cloud, FileIcon, Globe } from "lucide-react";
import { useRouter } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import { KnowledgeDropdown } from "@/components/knowledge-dropdown";
import { ProtectedRoute } from "@/components/protected-route";
import { Button } from "@/components/ui/button";
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context";
import { type File, useGetSearchQuery } from "../api/queries/useGetSearchQuery";
import "@/components/AgGrid/registerAgGridModules";
import "@/components/AgGrid/agGridStyles.css";
import { toast } from "sonner";
import { KnowledgeActionsDropdown } from "@/components/knowledge-actions-dropdown";
import { KnowledgeSearchInput } from "@/components/knowledge-search-input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { StatusBadge } from "@/components/ui/status-badge";
import { DeleteConfirmationDialog } from "../../components/delete-confirmation-dialog";
import GoogleDriveIcon from "../../components/icons/google-drive-logo";
import OneDriveIcon from "../../components/icons/one-drive-logo";
import SharePointIcon from "../../components/icons/share-point-logo";
import { useDeleteDocument } from "../api/mutations/useDeleteDocument";
// Function to get the appropriate icon for a connector type
function getSourceIcon(connectorType?: string) {
switch (connectorType) {
case "google_drive":
return (
<GoogleDriveIcon className="h-4 w-4 text-foreground flex-shrink-0" />
);
case "onedrive":
return <OneDriveIcon className="h-4 w-4 text-foreground flex-shrink-0" />;
case "sharepoint":
return (
<SharePointIcon className="h-4 w-4 text-foreground flex-shrink-0" />
);
case "url":
return <Globe className="h-4 w-4 text-muted-foreground flex-shrink-0" />;
case "s3":
return <Cloud className="h-4 w-4 text-foreground flex-shrink-0" />;
default:
return (
<FileIcon className="h-4 w-4 text-muted-foreground flex-shrink-0" />
);
}
}
function SearchPage() {
const router = useRouter();
const { files: taskFiles, refreshTasks } = useTask();
const { parsedFilterData, queryOverride } = useKnowledgeFilter();
const [selectedRows, setSelectedRows] = useState<File[]>([]);
const [showBulkDeleteDialog, setShowBulkDeleteDialog] = useState(false);
const deleteDocumentMutation = useDeleteDocument();
useEffect(() => {
refreshTasks();
}, [refreshTasks]);
const { data: searchData = [], isFetching } = useGetSearchQuery(
queryOverride,
parsedFilterData,
);
// Convert TaskFiles to File format and merge with backend results
const taskFilesAsFiles: File[] = taskFiles.map((taskFile) => {
return {
filename: taskFile.filename,
mimetype: taskFile.mimetype,
source_url: taskFile.source_url,
size: taskFile.size,
connector_type: taskFile.connector_type,
status: taskFile.status,
error: taskFile.error,
embedding_model: taskFile.embedding_model,
embedding_dimensions: taskFile.embedding_dimensions,
};
});
// Create a map of task files by filename for quick lookup
const taskFileMap = new Map(
taskFilesAsFiles.map((file) => [file.filename, file]),
);
// Override backend files with task file status if they exist
const backendFiles = (searchData as File[])
.map((file) => {
const taskFile = taskFileMap.get(file.filename);
if (taskFile) {
// Override backend file with task file data (includes status)
return { ...file, ...taskFile };
}
return file;
})
.filter((file) => {
// Only filter out files that are currently processing AND in taskFiles
const taskFile = taskFileMap.get(file.filename);
return !taskFile || taskFile.status !== "processing";
});
const filteredTaskFiles = taskFilesAsFiles.filter((taskFile) => {
return (
taskFile.status !== "active" &&
!backendFiles.some(
(backendFile) => backendFile.filename === taskFile.filename,
)
);
});
// Combine task files first, then backend files
const fileResults = [...backendFiles, ...filteredTaskFiles];
const gridRef = useRef<AgGridReact>(null);
const columnDefs: ColDef<File>[] = [
{
field: "filename",
headerName: "Source",
checkboxSelection: (params: CheckboxSelectionCallbackParams<File>) =>
(params?.data?.status || "active") === "active",
headerCheckboxSelection: true,
initialFlex: 2,
minWidth: 220,
cellRenderer: ({ data, value }: CustomCellRendererProps<File>) => {
// Read status directly from data on each render
const status = data?.status || "active";
const isActive = status === "active";
return (
<div className="flex items-center overflow-hidden w-full">
<div
className={`transition-opacity duration-200 ${
isActive ? "w-0" : "w-7"
}`}
></div>
<button
type="button"
className="flex items-center gap-2 cursor-pointer hover:text-blue-600 transition-colors text-left flex-1 overflow-hidden"
onClick={() => {
if (!isActive) {
return;
}
router.push(
`/knowledge/chunks?filename=${encodeURIComponent(
data?.filename ?? "",
)}`,
);
}}
>
{getSourceIcon(data?.connector_type)}
<span className="font-medium text-foreground truncate">
{value}
</span>
</button>
</div>
);
},
},
{
field: "size",
headerName: "Size",
valueFormatter: (params: ValueFormatterParams<File>) =>
params.value ? `${Math.round(params.value / 1024)} KB` : "-",
},
{
field: "mimetype",
headerName: "Type",
},
{
field: "owner",
headerName: "Owner",
valueFormatter: (params: ValueFormatterParams<File>) =>
params.data?.owner_name || params.data?.owner_email || "—",
},
{
field: "chunkCount",
headerName: "Chunks",
valueFormatter: (params: ValueFormatterParams<File>) =>
params.data?.chunkCount?.toString() || "-",
},
{
field: "avgScore",
headerName: "Avg score",
cellRenderer: ({ value }: CustomCellRendererProps<File>) => {
return (
<span className="text-xs text-accent-emerald-foreground bg-accent-emerald px-2 py-1 rounded">
{value?.toFixed(2) ?? "-"}
</span>
);
},
},
{
field: "embedding_model",
headerName: "Embedding model",
minWidth: 200,
cellRenderer: ({ data }: CustomCellRendererProps<File>) => (
<span className="text-xs text-muted-foreground">
{data?.embedding_model || "—"}
</span>
),
},
{
field: "embedding_dimensions",
headerName: "Dimensions",
width: 110,
cellRenderer: ({ data }: CustomCellRendererProps<File>) => (
<span className="text-xs text-muted-foreground">
{typeof data?.embedding_dimensions === "number"
? data.embedding_dimensions.toString()
: "—"}
</span>
),
},
{
field: "status",
headerName: "Status",
cellRenderer: ({ data }: CustomCellRendererProps<File>) => {
const status = data?.status || "active";
const error =
typeof data?.error === "string" && data.error.trim().length > 0
? data.error.trim()
: undefined;
if (status === "failed" && error) {
return (
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 text-red-500 transition hover:text-red-400"
aria-label="View ingestion error"
>
<StatusBadge
status={status}
className="pointer-events-none"
/>
</button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Ingestion failed</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{data?.filename || "Unknown file"}
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-destructive/20 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
</DialogContent>
</Dialog>
);
}
return <StatusBadge status={status} />;
},
},
{
cellRenderer: ({ data }: CustomCellRendererProps<File>) => {
const status = data?.status || "active";
if (status !== "active") {
return null;
}
return <KnowledgeActionsDropdown filename={data?.filename || ""} />;
},
cellStyle: {
alignItems: "center",
display: "flex",
justifyContent: "center",
padding: 0,
},
colId: "actions",
filter: false,
minWidth: 0,
width: 40,
resizable: false,
sortable: false,
initialFlex: 0,
},
];
const defaultColDef: ColDef<File> = {
resizable: false,
suppressMovable: true,
initialFlex: 1,
minWidth: 100,
};
const onSelectionChanged = useCallback(() => {
if (gridRef.current) {
const selectedNodes = gridRef.current.api.getSelectedRows();
setSelectedRows(selectedNodes);
}
}, []);
const handleBulkDelete = async () => {
if (selectedRows.length === 0) return;
try {
// Delete each file individually since the API expects one filename at a time
const deletePromises = selectedRows.map((row) =>
deleteDocumentMutation.mutateAsync({ filename: row.filename }),
);
await Promise.all(deletePromises);
toast.success(
`Successfully deleted ${selectedRows.length} document${
selectedRows.length > 1 ? "s" : ""
}`,
);
setSelectedRows([]);
setShowBulkDeleteDialog(false);
// Clear selection in the grid
if (gridRef.current) {
gridRef.current.api.deselectAll();
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to delete some documents",
);
}
};
return (
<>
<div className="flex flex-col h-full">
<div className="flex items-center justify-between mb-6">
<h2 className="text-lg font-semibold">Project Knowledge</h2>
</div>
{/* Search Input Area */}
<div className="flex-1 flex items-center flex-shrink-0 flex-wrap-reverse gap-3 mb-6">
<KnowledgeSearchInput />
{/* //TODO: Implement sync button */}
{/* <Button
type="button"
variant="outline"
className="rounded-lg flex-shrink-0"
onClick={() => alert("Not implemented")}
>
Sync
</Button> */}
{selectedRows.length > 0 && (
<Button
type="button"
variant="destructive"
className="rounded-lg flex-shrink-0"
onClick={() => setShowBulkDeleteDialog(true)}
>
Delete
</Button>
)}
<div className="ml-auto">
<KnowledgeDropdown />
</div>
</div>
<AgGridReact
className="w-full overflow-auto"
columnDefs={columnDefs as ColDef<File>[]}
defaultColDef={defaultColDef}
loading={isFetching}
ref={gridRef}
theme={themeQuartz.withParams({ browserColorScheme: "inherit" })}
rowData={fileResults}
rowSelection="multiple"
rowMultiSelectWithClick={false}
suppressRowClickSelection={true}
getRowId={(params: GetRowIdParams<File>) => params.data?.filename}
domLayout="normal"
onSelectionChanged={onSelectionChanged}
noRowsOverlayComponent={() => (
<div className="text-center pb-[45px]">
<div className="text-lg text-primary font-semibold">
No knowledge
</div>
<div className="text-sm mt-1 text-muted-foreground">
Add files from local or your preferred cloud.
</div>
</div>
)}
/>
</div>
{/* Bulk Delete Confirmation Dialog */}
<DeleteConfirmationDialog
open={showBulkDeleteDialog}
onOpenChange={setShowBulkDeleteDialog}
title="Delete Documents"
description={`Are you sure you want to delete ${
selectedRows.length
} document${
selectedRows.length > 1 ? "s" : ""
}? This will remove all chunks and data associated with these documents. This action cannot be undone.
Documents to be deleted:
${selectedRows.map((row) => `${row.filename}`).join("\n")}`}
confirmText="Delete All"
onConfirm={handleBulkDelete}
isLoading={deleteDocumentMutation.isPending}
/>
</>
);
}
export default function ProtectedSearchPage() {
return (
<ProtectedRoute>
<SearchPage />
</ProtectedRoute>
);
}

View file

@ -0,0 +1,73 @@
"use client";
import { Loader2 } from "lucide-react";
import { useRouter, useSearchParams } from "next/navigation";
import { Suspense, useEffect } from "react";
import GoogleLogo from "@/components/icons/google-logo";
import Logo from "@/components/icons/openrag-logo";
import { Button } from "@/components/ui/button";
import { useAuth } from "@/contexts/auth-context";
function LoginPageContent() {
const { isLoading, isAuthenticated, isNoAuthMode, login } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get("redirect") || "/chat";
// Redirect if already authenticated or in no-auth mode
useEffect(() => {
if (!isLoading && (isAuthenticated || isNoAuthMode)) {
router.push(redirect);
}
}, [isLoading, isAuthenticated, isNoAuthMode, router, redirect]);
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
);
}
if (isAuthenticated || isNoAuthMode) {
return null; // Will redirect in useEffect
}
return (
<div className="min-h-dvh relative flex gap-4 flex-col items-center justify-center bg-card rounded-lg m-4">
<div className="flex flex-col items-center justify-center gap-4 z-10 ">
<Logo className="fill-primary" width={50} height={40} />
<div className="flex flex-col items-center justify-center gap-16">
<h1 className="text-2xl font-medium font-chivo">
Welcome to OpenRAG
</h1>
<Button onClick={login} className="w-80 gap-1.5" size="lg">
<GoogleLogo className="h-4 w-4" />
Continue with Google
</Button>
</div>
</div>
</div>
);
}
export default function LoginPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin" />
<p className="text-muted-foreground">Loading...</p>
</div>
</div>
}
>
<LoginPageContent />
</Suspense>
);
}

View file

@ -0,0 +1,214 @@
"use client";
import { AnimatePresence, motion } from "framer-motion";
import { CheckIcon, XIcon } from "lucide-react";
import { useEffect, useState } from "react";
import AnimatedProcessingIcon from "@/components/icons/animated-processing-icon";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { cn } from "@/lib/utils";
export function AnimatedProviderSteps({
currentStep,
isCompleted,
setCurrentStep,
steps,
storageKey = "provider-steps",
processingStartTime,
hasError = false,
}: {
currentStep: number;
isCompleted: boolean;
setCurrentStep: (step: number) => void;
steps: string[];
storageKey?: string;
processingStartTime?: number | null;
hasError?: boolean;
}) {
const [startTime, setStartTime] = useState<number | null>(null);
const [elapsedTime, setElapsedTime] = useState<number>(0);
// Initialize start time from prop or local storage
useEffect(() => {
const storedElapsedTime = localStorage.getItem(`${storageKey}-elapsed`);
if (isCompleted && storedElapsedTime) {
// If completed, use stored elapsed time
setElapsedTime(parseFloat(storedElapsedTime));
} else if (processingStartTime) {
// Use the start time passed from parent (when user clicked Complete)
setStartTime(processingStartTime);
}
}, [storageKey, isCompleted, processingStartTime]);
// Progress through steps
useEffect(() => {
if (currentStep < steps.length - 1 && !isCompleted) {
const interval = setInterval(() => {
setCurrentStep(currentStep + 1);
}, 1500);
return () => clearInterval(interval);
}
}, [currentStep, setCurrentStep, steps, isCompleted]);
// Calculate and store elapsed time when completed
useEffect(() => {
if (isCompleted && startTime) {
const elapsed = Date.now() - startTime;
setElapsedTime(elapsed);
localStorage.setItem(`${storageKey}-elapsed`, elapsed.toString());
}
}, [isCompleted, startTime, storageKey]);
const isDone = currentStep >= steps.length && !isCompleted && !hasError;
return (
<AnimatePresence mode="wait">
{!isCompleted ? (
<motion.div
key="processing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="flex flex-col gap-2"
>
<div className="flex items-center gap-2">
<div
className={cn(
"transition-all duration-300 relative",
isDone || hasError ? "w-3.5 h-3.5" : "w-6 h-6",
)}
>
<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",
)}
/>
<XIcon
className={cn(
"text-accent-red-foreground shrink-0 w-3.5 h-3.5 absolute inset-0 transition-all duration-150",
hasError ? "opacity-100" : "opacity-0",
)}
/>
<AnimatedProcessingIcon
className={cn(
"text-current shrink-0 absolute inset-0 transition-all duration-150",
isDone || hasError ? "opacity-0" : "opacity-100",
)}
/>
</div>
<span className="!text-mmd font-medium text-muted-foreground">
{hasError ? "Error" : isDone ? "Done" : "Thinking"}
</span>
</div>
<div className="overflow-hidden">
<AnimatePresence>
{!isDone && !hasError && (
<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-4 overflow-y-hidden relative h-6"
>
<div className="w-px h-6 bg-border ml-3" />
<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>
</motion.div>
) : (
<motion.div
key="completed"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
>
<Accordion type="single" collapsible>
<AccordionItem value="steps" className="border-none">
<AccordionTrigger className="hover:no-underline p-0 py-2">
<div className="flex items-center gap-2">
<span className="text-mmd font-medium text-muted-foreground">
{`Initialized in ${(elapsedTime / 1000).toFixed(1)} seconds`}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="pl-0 pt-2 pb-0">
<div className="relative pl-1">
{/* Connecting line on the left */}
<motion.div
className="absolute left-[7px] top-0 bottom-0 w-px bg-border z-0"
initial={{ scaleY: 0 }}
animate={{ scaleY: 1 }}
transition={{ duration: 0.3, ease: "easeOut" }}
style={{ transformOrigin: "top" }}
/>
<div className="space-y-3 ml-4">
<AnimatePresence>
{steps.map((step, index) => (
<motion.div
key={step}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{
duration: 0.3,
delay: index * 0.05,
}}
className="flex items-center gap-1.5"
>
<motion.div
className="relative w-3.5 h-3.5 shrink-0 z-10 bg-background"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
duration: 0.2,
delay: index * 0.05 + 0.1,
}}
>
<motion.div
key="check"
initial={{ scale: 0, rotate: -180 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ duration: 0.3 }}
>
<CheckIcon className="text-accent-emerald-foreground w-3.5 h-3.5" />
</motion.div>
</motion.div>
<span className="text-mmd text-muted-foreground">
{step}
</span>
</motion.div>
))}
</AnimatePresence>
</div>
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</motion.div>
)}
</AnimatePresence>
);
}

View file

@ -0,0 +1,154 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import AnthropicLogo from "@/components/icons/anthropic-logo";
import { LabelInput } from "@/components/label-input";
import { LabelWrapper } from "@/components/label-wrapper";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/lib/debounce";
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
import { useGetAnthropicModelsQuery } from "../../api/queries/useGetModelsQuery";
import { useModelSelection } from "../_hooks/useModelSelection";
import { useUpdateSettings } from "../_hooks/useUpdateSettings";
import { AdvancedOnboarding } from "./advanced";
export function AnthropicOnboarding({
setSettings,
sampleDataset,
setSampleDataset,
setIsLoadingModels,
isEmbedding = false,
hasEnvApiKey = false,
}: {
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
sampleDataset: boolean;
setSampleDataset: (dataset: boolean) => void;
setIsLoadingModels?: (isLoading: boolean) => void;
isEmbedding?: boolean;
hasEnvApiKey?: boolean;
}) {
const [apiKey, setApiKey] = useState("");
const [getFromEnv, setGetFromEnv] = useState(hasEnvApiKey);
const debouncedApiKey = useDebouncedValue(apiKey, 500);
// Fetch models from API when API key is provided
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetAnthropicModelsQuery(
getFromEnv
? { apiKey: "" }
: debouncedApiKey
? { apiKey: debouncedApiKey }
: undefined,
{ enabled: debouncedApiKey !== "" || getFromEnv },
);
// Use custom hook for model selection logic
const {
languageModel,
embeddingModel,
setLanguageModel,
setEmbeddingModel,
languageModels,
embeddingModels,
} = useModelSelection(modelsData, isEmbedding);
const handleSampleDatasetChange = (dataset: boolean) => {
setSampleDataset(dataset);
};
const handleGetFromEnvChange = (fromEnv: boolean) => {
setGetFromEnv(fromEnv);
if (fromEnv) {
setApiKey("");
}
setEmbeddingModel?.("");
setLanguageModel?.("");
};
useEffect(() => {
setIsLoadingModels?.(isLoadingModels);
}, [isLoadingModels, setIsLoadingModels]);
// Update settings when values change
useUpdateSettings(
"anthropic",
{
apiKey,
languageModel,
embeddingModel,
},
setSettings,
isEmbedding,
);
return (
<>
<div className="space-y-5">
<LabelWrapper
label="Use environment Anthropic API key"
id="get-api-key"
description="Reuse the key from your environment config. Turn off to enter a different key."
flex
>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={getFromEnv}
onCheckedChange={handleGetFromEnvChange}
disabled={!hasEnvApiKey}
/>
</div>
</TooltipTrigger>
{!hasEnvApiKey && (
<TooltipContent>
Anthropic API key not detected in the environment.
</TooltipContent>
)}
</Tooltip>
</LabelWrapper>
{!getFromEnv && (
<div className="space-y-1">
<LabelInput
label="Anthropic API key"
helperText="The API key for your Anthropic account."
className={modelsError ? "!border-destructive" : ""}
id="api-key"
type="password"
required
placeholder="sk-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
/>
{isLoadingModels && (
<p className="text-mmd text-muted-foreground">
Validating API key...
</p>
)}
{modelsError && (
<p className="text-mmd text-destructive">
Invalid Anthropic API key. Verify or replace the key.
</p>
)}
</div>
)}
</div>
<AdvancedOnboarding
icon={<AnthropicLogo className="w-4 h-4 text-[#D97757" />}
languageModels={languageModels}
embeddingModels={embeddingModels}
languageModel={languageModel}
embeddingModel={embeddingModel}
sampleDataset={sampleDataset}
setLanguageModel={setLanguageModel}
setSampleDataset={handleSampleDatasetChange}
setEmbeddingModel={setEmbeddingModel}
/>
</>
);
}

View file

@ -0,0 +1,212 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import IBMLogo from "@/components/icons/ibm-logo";
import { LabelInput } from "@/components/label-input";
import { LabelWrapper } from "@/components/label-wrapper";
import { useDebouncedValue } from "@/lib/debounce";
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
import { useGetIBMModelsQuery } from "../../api/queries/useGetModelsQuery";
import { useModelSelection } from "../_hooks/useModelSelection";
import { useUpdateSettings } from "../_hooks/useUpdateSettings";
import { AdvancedOnboarding } from "./advanced";
import { ModelSelector } from "./model-selector";
export function IBMOnboarding({
isEmbedding = false,
setSettings,
sampleDataset,
setSampleDataset,
setIsLoadingModels,
alreadyConfigured = false,
}: {
isEmbedding?: boolean;
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
sampleDataset: boolean;
setSampleDataset: (dataset: boolean) => void;
setIsLoadingModels?: (isLoading: boolean) => void;
alreadyConfigured?: boolean;
}) {
const [endpoint, setEndpoint] = useState("https://us-south.ml.cloud.ibm.com");
const [apiKey, setApiKey] = useState("");
const [projectId, setProjectId] = useState("");
const options = [
{
value: "https://us-south.ml.cloud.ibm.com",
label: "https://us-south.ml.cloud.ibm.com",
default: true,
},
{
value: "https://eu-de.ml.cloud.ibm.com",
label: "https://eu-de.ml.cloud.ibm.com",
default: false,
},
{
value: "https://eu-gb.ml.cloud.ibm.com",
label: "https://eu-gb.ml.cloud.ibm.com",
default: false,
},
{
value: "https://au-syd.ml.cloud.ibm.com",
label: "https://au-syd.ml.cloud.ibm.com",
default: false,
},
{
value: "https://jp-tok.ml.cloud.ibm.com",
label: "https://jp-tok.ml.cloud.ibm.com",
default: false,
},
{
value: "https://ca-tor.ml.cloud.ibm.com",
label: "https://ca-tor.ml.cloud.ibm.com",
default: false,
},
];
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
const debouncedApiKey = useDebouncedValue(apiKey, 500);
const debouncedProjectId = useDebouncedValue(projectId, 500);
// Fetch models from API when all credentials are provided
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetIBMModelsQuery(
{
endpoint: debouncedEndpoint,
apiKey: debouncedApiKey,
projectId: debouncedProjectId,
},
{
enabled: !!debouncedEndpoint && !!debouncedApiKey && !!debouncedProjectId,
},
);
// Use custom hook for model selection logic
const {
languageModel,
embeddingModel,
setLanguageModel,
setEmbeddingModel,
languageModels,
embeddingModels,
} = useModelSelection(modelsData, isEmbedding);
const handleSampleDatasetChange = (dataset: boolean) => {
setSampleDataset(dataset);
};
useEffect(() => {
setIsLoadingModels?.(isLoadingModels);
}, [isLoadingModels, setIsLoadingModels]);
// Update settings when values change
useUpdateSettings(
"watsonx",
{
endpoint,
apiKey,
projectId,
languageModel,
embeddingModel,
},
setSettings,
isEmbedding,
);
return (
<>
<div className="space-y-4">
<LabelWrapper
label="watsonx.ai API Endpoint"
helperText="Base URL of the API"
id="api-endpoint"
required
>
<div className="space-y-1">
<ModelSelector
options={alreadyConfigured ? [] : options}
value={endpoint}
custom
onValueChange={alreadyConfigured ? () => {} : setEndpoint}
searchPlaceholder="Search endpoint..."
noOptionsPlaceholder={
alreadyConfigured
? "https://•••••••••••••••••••••••••••••••••••••••••"
: "No endpoints available"
}
placeholder="Select endpoint..."
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing endpoint from model provider selection.
</p>
)}
</div>
</LabelWrapper>
<div className="space-y-1">
<LabelInput
label="watsonx Project ID"
helperText="Project ID for the model"
id="project-id"
required
placeholder={
alreadyConfigured ? "••••••••••••••••••••••••" : "your-project-id"
}
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing project ID from model provider selection.
</p>
)}
</div>
<div className="space-y-1">
<LabelInput
label="watsonx API key"
helperText="API key to access watsonx.ai"
id="api-key"
type="password"
required
placeholder={
alreadyConfigured
? "•••••••••••••••••••••••••••••••••••••••••"
: "your-api-key"
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing API key from model provider selection.
</p>
)}
</div>
{isLoadingModels && (
<p className="text-mmd text-muted-foreground">
Validating configuration...
</p>
)}
{modelsError && (
<p className="text-mmd text-accent-amber-foreground">
Connection failed. Check your configuration.
</p>
)}
</div>
<AdvancedOnboarding
icon={<IBMLogo className="w-4 h-4" />}
languageModels={languageModels}
embeddingModels={embeddingModels}
languageModel={languageModel}
embeddingModel={embeddingModel}
sampleDataset={sampleDataset}
setLanguageModel={setLanguageModel}
setEmbeddingModel={setEmbeddingModel}
setSampleDataset={handleSampleDatasetChange}
/>
</>
);
}

View file

@ -0,0 +1,173 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import OllamaLogo from "@/components/icons/ollama-logo";
import { LabelInput } from "@/components/label-input";
import { LabelWrapper } from "@/components/label-wrapper";
import { useDebouncedValue } from "@/lib/debounce";
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
import { useGetOllamaModelsQuery } from "../../api/queries/useGetModelsQuery";
import { useModelSelection } from "../_hooks/useModelSelection";
import { useUpdateSettings } from "../_hooks/useUpdateSettings";
import { ModelSelector } from "./model-selector";
export function OllamaOnboarding({
setSettings,
sampleDataset,
setSampleDataset,
setIsLoadingModels,
isEmbedding = false,
alreadyConfigured = false,
}: {
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
sampleDataset: boolean;
setSampleDataset: (dataset: boolean) => void;
setIsLoadingModels?: (isLoading: boolean) => void;
isEmbedding?: boolean;
alreadyConfigured?: boolean;
}) {
const [endpoint, setEndpoint] = useState(`http://localhost:11434`);
const [showConnecting, setShowConnecting] = useState(false);
const debouncedEndpoint = useDebouncedValue(endpoint, 500);
// Fetch models from API when endpoint is provided (debounced)
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetOllamaModelsQuery(
debouncedEndpoint ? { endpoint: debouncedEndpoint } : undefined,
);
// Use custom hook for model selection logic
const {
languageModel,
embeddingModel,
setLanguageModel,
setEmbeddingModel,
languageModels,
embeddingModels,
} = useModelSelection(modelsData, isEmbedding);
// Handle delayed display of connecting state
useEffect(() => {
let timeoutId: NodeJS.Timeout;
if (debouncedEndpoint && isLoadingModels) {
timeoutId = setTimeout(() => {
setIsLoadingModels?.(true);
setShowConnecting(true);
}, 500);
} else {
setShowConnecting(false);
setIsLoadingModels?.(false);
}
return () => {
if (timeoutId) {
clearTimeout(timeoutId);
}
};
}, [debouncedEndpoint, isLoadingModels, setIsLoadingModels]);
// Update settings when values change
useUpdateSettings(
"ollama",
{
endpoint,
languageModel,
embeddingModel,
},
setSettings,
isEmbedding,
);
// Check validation state based on models query
const hasConnectionError = debouncedEndpoint && modelsError;
const hasNoModels =
modelsData &&
!modelsData.language_models?.length &&
!modelsData.embedding_models?.length;
return (
<div className="space-y-4">
<div className="space-y-1">
<LabelInput
label="Ollama Base URL"
helperText="Base URL of your Ollama server"
id="api-endpoint"
required
placeholder={
alreadyConfigured
? "http://••••••••••••••••••••"
: "http://localhost:11434"
}
value={endpoint}
onChange={(e) => setEndpoint(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing endpoint from model provider selection.
</p>
)}
{showConnecting && (
<p className="text-mmd text-muted-foreground">
Connecting to Ollama server...
</p>
)}
{hasConnectionError && (
<p className="text-mmd text-accent-amber-foreground">
Can't reach Ollama at {debouncedEndpoint}. Update the base URL or
start the server.
</p>
)}
{hasNoModels && (
<p className="text-mmd text-accent-amber-foreground">
No models found. Install embedding and agent models on your Ollama
server.
</p>
)}
</div>
{isEmbedding && setEmbeddingModel && (
<LabelWrapper
label="Embedding model"
helperText="Model used for knowledge ingest and retrieval"
id="embedding-model"
required={true}
>
<ModelSelector
options={embeddingModels}
icon={<OllamaLogo className="w-4 h-4" />}
noOptionsPlaceholder={
isLoadingModels
? "Loading models..."
: "No embedding models detected. Install an embedding model to continue."
}
value={embeddingModel}
onValueChange={setEmbeddingModel}
/>
</LabelWrapper>
)}
{!isEmbedding && setLanguageModel && (
<LabelWrapper
label="Language model"
helperText="Model used for chat"
id="embedding-model"
required={true}
>
<ModelSelector
options={languageModels}
icon={<OllamaLogo className="w-4 h-4" />}
noOptionsPlaceholder={
isLoadingModels
? "Loading models..."
: "No language models detected. Install a language model to continue."
}
value={languageModel}
onValueChange={setLanguageModel}
/>
</LabelWrapper>
)}
</div>
);
}

View file

@ -0,0 +1,538 @@
"use client";
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
import { Info, X } from "lucide-react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import {
type OnboardingVariables,
useOnboardingMutation,
} from "@/app/api/mutations/useOnboardingMutation";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import { useDoclingHealth } from "@/components/docling-health-banner";
import AnthropicLogo from "@/components/icons/anthropic-logo";
import IBMLogo from "@/components/icons/ibm-logo";
import OllamaLogo from "@/components/icons/ollama-logo";
import OpenAILogo from "@/components/icons/openai-logo";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import { AnimatedProviderSteps } from "./animated-provider-steps";
import { AnthropicOnboarding } from "./anthropic-onboarding";
import { IBMOnboarding } from "./ibm-onboarding";
import { OllamaOnboarding } from "./ollama-onboarding";
import { OpenAIOnboarding } from "./openai-onboarding";
import { TabTrigger } from "./tab-trigger";
interface OnboardingCardProps {
onComplete: () => void;
isCompleted?: boolean;
isEmbedding?: boolean;
setIsLoadingModels?: (isLoading: boolean) => void;
setLoadingStatus?: (status: string[]) => void;
}
const STEP_LIST = [
"Setting up your model provider",
"Defining schema",
"Configuring Langflow",
];
const EMBEDDING_STEP_LIST = [
"Setting up your model provider",
"Defining schema",
"Configuring Langflow",
"Ingesting sample data",
];
const OnboardingCard = ({
onComplete,
isEmbedding = false,
isCompleted = false,
}: OnboardingCardProps) => {
const { isHealthy: isDoclingHealthy } = useDoclingHealth();
const [modelProvider, setModelProvider] = useState<string>(
isEmbedding ? "openai" : "anthropic",
);
const [sampleDataset, setSampleDataset] = useState<boolean>(true);
const [isLoadingModels, setIsLoadingModels] = useState<boolean>(false);
const queryClient = useQueryClient();
// Fetch current settings to check if providers are already configured
const { data: currentSettings } = useGetSettingsQuery();
const handleSetModelProvider = (provider: string) => {
setIsLoadingModels(false);
setModelProvider(provider);
setSettings({
[isEmbedding ? "embedding_provider" : "llm_provider"]: provider,
embedding_model: "",
llm_model: "",
});
setError(null);
};
// Check if the selected provider is already configured
const isProviderAlreadyConfigured = (provider: string): boolean => {
if (!isEmbedding || !currentSettings?.providers) return false;
// Check if provider has been explicitly configured (not just from env vars)
if (provider === "openai") {
return currentSettings.providers.openai?.configured === true;
} else if (provider === "anthropic") {
return currentSettings.providers.anthropic?.configured === true;
} else if (provider === "watsonx") {
return currentSettings.providers.watsonx?.configured === true;
} else if (provider === "ollama") {
return currentSettings.providers.ollama?.configured === true;
}
return false;
};
const showProviderConfiguredMessage =
isProviderAlreadyConfigured(modelProvider);
const providerAlreadyConfigured =
isEmbedding && showProviderConfiguredMessage;
const totalSteps = isEmbedding
? EMBEDDING_STEP_LIST.length
: STEP_LIST.length;
const [settings, setSettings] = useState<OnboardingVariables>({
[isEmbedding ? "embedding_provider" : "llm_provider"]: modelProvider,
embedding_model: "",
llm_model: "",
// Provider-specific fields will be set by provider components
openai_api_key: "",
anthropic_api_key: "",
watsonx_api_key: "",
watsonx_endpoint: "",
watsonx_project_id: "",
ollama_endpoint: "",
});
const [currentStep, setCurrentStep] = useState<number | null>(
isCompleted ? totalSteps : null,
);
const [processingStartTime, setProcessingStartTime] = useState<number | null>(
null,
);
const [error, setError] = useState<string | 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 || !isEmbedding) {
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 &&
!isCompleted
) {
// Set to final step to show "Done"
setCurrentStep(totalSteps);
// Wait a bit before completing
setTimeout(() => {
onComplete();
}, 1000);
}
}, [tasks, currentStep, onComplete, isCompleted, isEmbedding, totalSteps]);
// Mutations
const onboardingMutation = useOnboardingMutation({
onSuccess: (data) => {
console.log("Onboarding completed successfully", data);
// Update provider health cache to healthy since backend just validated
const provider =
(isEmbedding ? settings.embedding_provider : settings.llm_provider) ||
modelProvider;
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: provider,
};
queryClient.setQueryData(["provider", "health"], healthData);
setError(null);
if (!isEmbedding) {
setCurrentStep(totalSteps);
setTimeout(() => {
onComplete();
}, 1000);
} else {
setCurrentStep(0);
}
},
onError: (error) => {
setError(error.message);
setCurrentStep(totalSteps);
// Reset to provider selection after 1 second
setTimeout(() => {
setCurrentStep(null);
}, 1000);
},
});
const handleComplete = () => {
const currentProvider = isEmbedding
? settings.embedding_provider
: settings.llm_provider;
if (
!currentProvider ||
(isEmbedding &&
!settings.embedding_model &&
!showProviderConfiguredMessage) ||
(!isEmbedding && !settings.llm_model)
) {
toast.error("Please complete all required fields");
return;
}
// Clear any previous error
setError(null);
// Prepare onboarding data with provider-specific fields
const onboardingData: OnboardingVariables = {
sample_data: sampleDataset,
};
// Set the provider field
if (isEmbedding) {
onboardingData.embedding_provider = currentProvider;
// If provider is already configured, use the existing embedding model from settings
// Otherwise, use the embedding model from the form
if (
showProviderConfiguredMessage &&
currentSettings?.knowledge?.embedding_model
) {
onboardingData.embedding_model =
currentSettings.knowledge.embedding_model;
} else {
onboardingData.embedding_model = settings.embedding_model;
}
} else {
onboardingData.llm_provider = currentProvider;
onboardingData.llm_model = settings.llm_model;
}
// Add provider-specific credentials based on the selected provider
if (currentProvider === "openai" && settings.openai_api_key) {
onboardingData.openai_api_key = settings.openai_api_key;
} else if (currentProvider === "anthropic" && settings.anthropic_api_key) {
onboardingData.anthropic_api_key = settings.anthropic_api_key;
} else if (currentProvider === "watsonx") {
if (settings.watsonx_api_key) {
onboardingData.watsonx_api_key = settings.watsonx_api_key;
}
if (settings.watsonx_endpoint) {
onboardingData.watsonx_endpoint = settings.watsonx_endpoint;
}
if (settings.watsonx_project_id) {
onboardingData.watsonx_project_id = settings.watsonx_project_id;
}
} else if (currentProvider === "ollama" && settings.ollama_endpoint) {
onboardingData.ollama_endpoint = settings.ollama_endpoint;
}
// Record the start time when user clicks Complete
setProcessingStartTime(Date.now());
onboardingMutation.mutate(onboardingData);
setCurrentStep(0);
};
const isComplete =
(isEmbedding &&
(!!settings.embedding_model || showProviderConfiguredMessage)) ||
(!isEmbedding && !!settings.llm_model && isDoclingHealthy);
return (
<AnimatePresence mode="wait">
{currentStep === null ? (
<motion.div
key="onboarding-form"
initial={{ opacity: 0, y: -24 }}
animate={{ 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`}>
<AnimatePresence mode="wait">
{error && (
<motion.div
key="error"
initial={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
>
<div className="pb-6 flex items-center gap-4">
<X className="w-4 h-4 text-destructive shrink-0" />
<span className="text-mmd text-muted-foreground">
{error}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
<div className={`w-full flex flex-col gap-6`}>
<Tabs
defaultValue={modelProvider}
onValueChange={handleSetModelProvider}
>
<TabsList className="mb-4">
{!isEmbedding && (
<TabsTrigger
value="anthropic"
className={cn(
error &&
modelProvider === "anthropic" &&
"data-[state=active]:border-destructive",
)}
>
<TabTrigger
selected={modelProvider === "anthropic"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md border",
modelProvider === "anthropic"
? "bg-[#D97757]"
: "bg-muted",
)}
>
<AnthropicLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "anthropic"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
Anthropic
</TabTrigger>
</TabsTrigger>
)}
<TabsTrigger
value="openai"
className={cn(
error &&
modelProvider === "openai" &&
"data-[state=active]:border-destructive",
)}
>
<TabTrigger
selected={modelProvider === "openai"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md border",
modelProvider === "openai" ? "bg-white" : "bg-muted",
)}
>
<OpenAILogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "openai"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
OpenAI
</TabTrigger>
</TabsTrigger>
<TabsTrigger
value="watsonx"
className={cn(
error &&
modelProvider === "watsonx" &&
"data-[state=active]:border-destructive",
)}
>
<TabTrigger
selected={modelProvider === "watsonx"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md border",
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
</TabTrigger>
</TabsTrigger>
<TabsTrigger
value="ollama"
className={cn(
error &&
modelProvider === "ollama" &&
"data-[state=active]:border-destructive",
)}
>
<TabTrigger
selected={modelProvider === "ollama"}
isLoading={isLoadingModels}
>
<div
className={cn(
"flex items-center justify-center gap-2 w-8 h-8 rounded-md border",
modelProvider === "ollama" ? "bg-white" : "bg-muted",
)}
>
<OllamaLogo
className={cn(
"w-4 h-4 shrink-0",
modelProvider === "ollama"
? "text-black"
: "text-muted-foreground",
)}
/>
</div>
Ollama
</TabTrigger>
</TabsTrigger>
</TabsList>
{!isEmbedding && (
<TabsContent value="anthropic">
<AnthropicOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
isEmbedding={isEmbedding}
hasEnvApiKey={
currentSettings?.providers?.anthropic?.has_api_key ===
true
}
/>
</TabsContent>
)}
<TabsContent value="openai">
<OpenAIOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
isEmbedding={isEmbedding}
hasEnvApiKey={
currentSettings?.providers?.openai?.has_api_key === true
}
alreadyConfigured={providerAlreadyConfigured}
/>
</TabsContent>
<TabsContent value="watsonx">
<IBMOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
isEmbedding={isEmbedding}
alreadyConfigured={providerAlreadyConfigured}
/>
</TabsContent>
<TabsContent value="ollama">
<OllamaOnboarding
setSettings={setSettings}
sampleDataset={sampleDataset}
setSampleDataset={setSampleDataset}
setIsLoadingModels={setIsLoadingModels}
isEmbedding={isEmbedding}
alreadyConfigured={providerAlreadyConfigured}
/>
</TabsContent>
</Tabs>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Button
size="sm"
onClick={handleComplete}
disabled={!isComplete || isLoadingModels}
loading={onboardingMutation.isPending}
>
<span className="select-none">Complete</span>
</Button>
</div>
</TooltipTrigger>
{!isComplete && (
<TooltipContent>
{isLoadingModels
? "Loading models..."
: !!settings.llm_model &&
!!settings.embedding_model &&
!isDoclingHealthy
? "docling-serve must be running to continue"
: "Please fill in all required fields"}
</TooltipContent>
)}
</Tooltip>
</div>
</div>
</motion.div>
) : (
<motion.div
key="provider-steps"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 24 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
<AnimatedProviderSteps
currentStep={currentStep}
isCompleted={isCompleted}
setCurrentStep={setCurrentStep}
steps={isEmbedding ? EMBEDDING_STEP_LIST : STEP_LIST}
processingStartTime={processingStartTime}
hasError={!!error}
/>
</motion.div>
)}
</AnimatePresence>
);
};
export default OnboardingCard;

View file

@ -0,0 +1,159 @@
"use client";
import { useEffect, useState } from "react";
import { StickToBottom } from "use-stick-to-bottom";
import { AssistantMessage } from "@/app/chat/_components/assistant-message";
import Nudges from "@/app/chat/_components/nudges";
import { UserMessage } from "@/app/chat/_components/user-message";
import type { Message } from "@/app/chat/_types/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 { 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 === 2 && !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 - LLM Provider */}
<OnboardingStep
isVisible={currentStep >= 0}
isCompleted={currentStep > 0}
showCompleted={true}
text="Let's get started by setting up your LLM provider."
>
<OnboardingCard
onComplete={() => {
handleStepComplete();
}}
isCompleted={currentStep > 0}
/>
</OnboardingStep>
{/* Step 2 - Embedding provider and ingestion */}
<OnboardingStep
isVisible={currentStep >= 1}
isCompleted={currentStep > 1}
showCompleted={true}
text="Now, let's set up your embedding provider."
>
<OnboardingCard
isEmbedding={true}
onComplete={() => {
handleStepComplete();
}}
isCompleted={currentStep > 1}
/>
</OnboardingStep>
{/* Step 2 */}
<OnboardingStep
isVisible={currentStep >= 2}
isCompleted={currentStep > 2 || !!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 >= 2 && !!selectedNudge && (
<UserMessage
content={selectedNudge}
isCompleted={currentStep > 3}
/>
)}
{/* Assistant message - show streaming or final message */}
{currentStep >= 2 &&
!!selectedNudge &&
(displayMessage || isLoading) && (
<AssistantMessage
content={displayMessage?.content || ""}
functionCalls={displayMessage?.functionCalls}
messageIndex={0}
expandedFunctionCalls={new Set()}
onToggle={() => {}}
isStreaming={!!streamingMessage}
isCompleted={currentStep > 3}
/>
)}
{/* Step 3 */}
<OnboardingStep
isVisible={currentStep >= 3 && !isLoading && !!displayMessage}
isCompleted={currentStep > 3}
text="Lastly, let's add your data."
hideIcon={true}
>
<OnboardingUpload onComplete={handleStepComplete} />
</OnboardingStep>
</div>
</StickToBottom.Content>
</StickToBottom>
);
}

View file

@ -0,0 +1,127 @@
import { AnimatePresence, motion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";
import { Message } from "@/app/chat/_components/message";
import DogIcon from "@/components/icons/dog-icon";
import { MarkdownRenderer } from "@/components/markdown-renderer";
import { cn } from "@/lib/utils";
interface OnboardingStepProps {
text: string;
children?: ReactNode;
isVisible: boolean;
isCompleted?: boolean;
showCompleted?: boolean;
icon?: ReactNode;
isMarkdown?: boolean;
hideIcon?: boolean;
}
export function OnboardingStep({
text,
children,
isVisible,
isCompleted = false,
showCompleted = false,
icon,
isMarkdown = false,
hideIcon = false,
}: OnboardingStepProps) {
const [displayedText, setDisplayedText] = useState("");
const [showChildren, setShowChildren] = useState(false);
useEffect(() => {
if (!isVisible) {
setDisplayedText("");
setShowChildren(false);
return;
}
if (isCompleted) {
setDisplayedText(text);
setShowChildren(true);
return;
}
let currentIndex = 0;
setDisplayedText("");
setShowChildren(false);
const interval = setInterval(() => {
if (currentIndex < text.length) {
setDisplayedText(text.slice(0, currentIndex + 1));
currentIndex++;
} else {
clearInterval(interval);
setShowChildren(true);
}
}, 20); // 20ms per character
return () => clearInterval(interval);
}, [text, isVisible, isCompleted]);
if (!isVisible) return null;
return (
<motion.div
initial={{ opacity: 0, y: -20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.4, ease: "easeOut" }}
className={isCompleted ? "opacity-50" : ""}
>
<Message
icon={
hideIcon ? (
<div className="w-8 h-8 rounded-lg flex-shrink-0" />
) : (
icon || (
<div className="w-8 h-8 flex items-center justify-center flex-shrink-0 select-none">
<DogIcon
className="h-6 w-6 text-accent-foreground transition-colors duration-300"
disabled={isCompleted}
/>
</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}
{!showChildren && !isCompleted && (
<span className="inline-block w-1 h-3.5 bg-primary ml-1 animate-pulse" />
)}
</p>
)}
{children && (
<AnimatePresence>
{((showChildren && (!isCompleted || showCompleted)) ||
isMarkdown) && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.3, delay: 0.3, ease: "easeOut" }}
>
<div className="pt-4">{children}</div>
</motion.div>
)}
</AnimatePresence>
)}
</div>
</Message>
</motion.div>
);
}

View file

@ -2,7 +2,7 @@ import { AnimatePresence, motion } from "motion/react";
import { type ChangeEvent, useEffect, useRef, useState } from "react";
import { useGetNudgesQuery } from "@/app/api/queries/useGetNudgesQuery";
import { useGetTasksQuery } from "@/app/api/queries/useGetTasksQuery";
import { AnimatedProviderSteps } from "@/app/onboarding/components/animated-provider-steps";
import { AnimatedProviderSteps } from "@/app/onboarding/_components/animated-provider-steps";
import { Button } from "@/components/ui/button";
import { uploadFile } from "@/lib/upload-utils";

View file

@ -0,0 +1,168 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useState } from "react";
import OpenAILogo from "@/components/icons/openai-logo";
import { LabelInput } from "@/components/label-input";
import { LabelWrapper } from "@/components/label-wrapper";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDebouncedValue } from "@/lib/debounce";
import type { OnboardingVariables } from "../../api/mutations/useOnboardingMutation";
import { useGetOpenAIModelsQuery } from "../../api/queries/useGetModelsQuery";
import { useModelSelection } from "../_hooks/useModelSelection";
import { useUpdateSettings } from "../_hooks/useUpdateSettings";
import { AdvancedOnboarding } from "./advanced";
export function OpenAIOnboarding({
setSettings,
sampleDataset,
setSampleDataset,
setIsLoadingModels,
isEmbedding = false,
hasEnvApiKey = false,
alreadyConfigured = false,
}: {
setSettings: Dispatch<SetStateAction<OnboardingVariables>>;
sampleDataset: boolean;
setSampleDataset: (dataset: boolean) => void;
setIsLoadingModels?: (isLoading: boolean) => void;
isEmbedding?: boolean;
hasEnvApiKey?: boolean;
alreadyConfigured?: boolean;
}) {
const [apiKey, setApiKey] = useState("");
const [getFromEnv, setGetFromEnv] = useState(hasEnvApiKey);
const debouncedApiKey = useDebouncedValue(apiKey, 500);
// Fetch models from API when API key is provided
const {
data: modelsData,
isLoading: isLoadingModels,
error: modelsError,
} = useGetOpenAIModelsQuery(
getFromEnv
? { apiKey: "" }
: debouncedApiKey
? { apiKey: debouncedApiKey }
: undefined,
{ enabled: debouncedApiKey !== "" || getFromEnv },
);
// Use custom hook for model selection logic
const {
languageModel,
embeddingModel,
setLanguageModel,
setEmbeddingModel,
languageModels,
embeddingModels,
} = useModelSelection(modelsData, isEmbedding);
const handleSampleDatasetChange = (dataset: boolean) => {
setSampleDataset(dataset);
};
const handleGetFromEnvChange = (fromEnv: boolean) => {
setGetFromEnv(fromEnv);
if (fromEnv) {
setApiKey("");
}
setEmbeddingModel?.("");
setLanguageModel?.("");
};
useEffect(() => {
setIsLoadingModels?.(isLoadingModels);
}, [isLoadingModels, setIsLoadingModels]);
// Update settings when values change
useUpdateSettings(
"openai",
{
apiKey,
languageModel,
embeddingModel,
},
setSettings,
isEmbedding,
);
return (
<>
<div className="space-y-5">
{!alreadyConfigured && (
<LabelWrapper
label="Use environment OpenAI API key"
id="get-api-key"
description="Reuse the key from your environment config. Turn off to enter a different key."
flex
>
<Tooltip>
<TooltipTrigger asChild>
<div>
<Switch
checked={getFromEnv}
onCheckedChange={handleGetFromEnvChange}
disabled={!hasEnvApiKey}
/>
</div>
</TooltipTrigger>
{!hasEnvApiKey && (
<TooltipContent>
OpenAI API key not detected in the environment.
</TooltipContent>
)}
</Tooltip>
</LabelWrapper>
)}
{(!getFromEnv || alreadyConfigured) && (
<div className="space-y-1">
<LabelInput
label="OpenAI API key"
helperText="The API key for your OpenAI account."
className={modelsError ? "!border-destructive" : ""}
id="api-key"
type="password"
required
placeholder={
alreadyConfigured
? "sk-•••••••••••••••••••••••••••••••••••••••••"
: "sk-..."
}
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
disabled={alreadyConfigured}
/>
{alreadyConfigured && (
<p className="text-mmd text-muted-foreground">
Reusing key from model provider selection.
</p>
)}
{isLoadingModels && (
<p className="text-mmd text-muted-foreground">
Validating API key...
</p>
)}
{modelsError && (
<p className="text-mmd text-destructive">
Invalid OpenAI API key. Verify or replace the key.
</p>
)}
</div>
)}
</div>
<AdvancedOnboarding
icon={<OpenAILogo className="w-4 h-4" />}
languageModels={languageModels}
embeddingModels={embeddingModels}
languageModel={languageModel}
embeddingModel={embeddingModel}
sampleDataset={sampleDataset}
setLanguageModel={setLanguageModel}
setSampleDataset={handleSampleDatasetChange}
setEmbeddingModel={setEmbeddingModel}
/>
</>
);
}

View file

@ -0,0 +1,33 @@
import AnimatedProcessingIcon from "@/components/icons/animated-processing-icon";
import { cn } from "@/lib/utils";
export function TabTrigger({
children,
selected,
isLoading,
}: {
children: React.ReactNode;
selected: boolean;
isLoading: boolean;
}) {
return (
<div className="flex flex-col relative items-start justify-between gap-4 h-full w-full">
<div
className={cn(
"flex absolute items-center justify-center h-full w-full transition-opacity duration-200",
isLoading && selected ? "opacity-100" : "opacity-0",
)}
>
<AnimatedProcessingIcon className="text-current shrink-0 h-10 w-10" />
</div>
<div
className={cn(
"flex flex-col items-start justify-between gap-4 h-full w-full transition-opacity duration-200",
isLoading && selected ? "opacity-0" : "opacity-100",
)}
>
{children}
</div>
</div>
);
}

View file

@ -7,7 +7,7 @@ import { ProtectedRoute } from "@/components/protected-route";
import { DotPattern } from "@/components/ui/dot-pattern";
import { cn } from "@/lib/utils";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import OnboardingCard from "./components/onboarding-card";
import OnboardingCard from "./_components/onboarding-card";
function LegacyOnboardingPage() {
const router = useRouter();

View file

@ -0,0 +1,158 @@
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { useGetAnthropicModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import AnthropicLogo from "@/components/icons/anthropic-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AnthropicSettingsForm,
type AnthropicSettingsFormData,
} from "./anthropic-settings-form";
const AnthropicSettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const methods = useForm<AnthropicSettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
},
});
const { handleSubmit, watch } = methods;
const apiKey = watch("apiKey");
const { refetch: validateCredentials } = useGetAnthropicModelsQuery(
{
apiKey: apiKey,
},
{
enabled: false,
},
);
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "anthropic",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success(
"Anthropic credentials saved. Configure models in the Settings page.",
);
setOpen(false);
},
});
const onSubmit = async (data: AnthropicSettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
// Only validate if a new API key was entered
if (data.apiKey) {
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
if (result.isError) {
setValidationError(result.error);
return;
}
}
const payload: {
anthropic_api_key?: string;
} = {};
// Only include api_key if a value was entered
if (data.apiKey) {
payload.anthropic_api_key = data.apiKey;
}
// Submit the update
settingsMutation.mutate(payload);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<AnthropicLogo className="text-black" />
</div>
Anthropic Setup
</DialogTitle>
</DialogHeader>
<AnthropicSettingsForm
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default AnthropicSettingsDialog;

View file

@ -0,0 +1,215 @@
import { useRouter, useSearchParams } from "next/navigation";
import { type ReactNode, useEffect, useState } from "react";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import AnthropicLogo from "@/components/icons/anthropic-logo";
import IBMLogo from "@/components/icons/ibm-logo";
import OllamaLogo from "@/components/icons/ollama-logo";
import OpenAILogo from "@/components/icons/openai-logo";
import { useProviderHealth } from "@/components/provider-health-banner";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useAuth } from "@/contexts/auth-context";
import { cn } from "@/lib/utils";
import type { ModelProvider } from "../_helpers/model-helpers";
import AnthropicSettingsDialog from "./anthropic-settings-dialog";
import OllamaSettingsDialog from "./ollama-settings-dialog";
import OpenAISettingsDialog from "./openai-settings-dialog";
import WatsonxSettingsDialog from "./watsonx-settings-dialog";
export const ModelProviders = () => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const searchParams = useSearchParams();
const router = useRouter();
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const { health } = useProviderHealth();
const [dialogOpen, setDialogOpen] = useState<ModelProvider | undefined>();
const allProviderKeys: ModelProvider[] = [
"openai",
"ollama",
"watsonx",
"anthropic",
];
// Handle URL search param to open dialogs
useEffect(() => {
const searchParam = searchParams.get("setup");
if (searchParam && allProviderKeys.includes(searchParam as ModelProvider)) {
setDialogOpen(searchParam as ModelProvider);
}
}, [searchParams]);
// Function to close dialog and remove search param
const handleCloseDialog = () => {
setDialogOpen(undefined);
// Remove search param from URL
const params = new URLSearchParams(searchParams.toString());
params.delete("setup");
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
router.replace(newUrl);
};
const modelProvidersMap: Record<
ModelProvider,
{
name: string;
logo: (props: React.SVGProps<SVGSVGElement>) => ReactNode;
logoColor: string;
logoBgColor: string;
}
> = {
openai: {
name: "OpenAI",
logo: OpenAILogo,
logoColor: "text-black",
logoBgColor: "bg-white",
},
anthropic: {
name: "Anthropic",
logo: AnthropicLogo,
logoColor: "text-[#D97757]",
logoBgColor: "bg-white",
},
ollama: {
name: "Ollama",
logo: OllamaLogo,
logoColor: "text-black",
logoBgColor: "bg-white",
},
watsonx: {
name: "IBM watsonx.ai",
logo: IBMLogo,
logoColor: "text-white",
logoBgColor: "bg-[#1063FE]",
},
};
const currentLlmProvider =
(settings.agent?.llm_provider as ModelProvider) || "openai";
const currentEmbeddingProvider =
(settings.knowledge?.embedding_provider as ModelProvider) || "openai";
// Get all provider keys with active providers first
const activeProviders = new Set([
currentLlmProvider,
currentEmbeddingProvider,
]);
const sortedProviderKeys = [
...Array.from(activeProviders),
...allProviderKeys.filter((key) => !activeProviders.has(key)),
];
return (
<>
<div className="grid gap-6 xs:grid-cols-1 md:grid-cols-2 lg:grid-cols-4">
{sortedProviderKeys.map((providerKey) => {
const {
name,
logo: Logo,
logoColor,
logoBgColor,
} = modelProvidersMap[providerKey];
const isLlmProvider = providerKey === currentLlmProvider;
const isEmbeddingProvider = providerKey === currentEmbeddingProvider;
const isCurrentProvider = isLlmProvider || isEmbeddingProvider;
// Check if this specific provider is unhealthy
const hasLlmError = isLlmProvider && health?.llm_error;
const hasEmbeddingError =
isEmbeddingProvider && health?.embedding_error;
const isProviderUnhealthy = hasLlmError || hasEmbeddingError;
return (
<Card
key={providerKey}
className={cn(
"relative flex flex-col",
!settings.providers?.[providerKey]?.configured &&
"text-muted-foreground",
isProviderUnhealthy && "border-destructive",
)}
>
<CardHeader>
<div className="flex flex-col items-start justify-between">
<div className="flex flex-col gap-3">
<div className="mb-1">
<div
className={cn(
"w-8 h-8 rounded flex items-center justify-center border",
settings.providers?.[providerKey]?.configured
? logoBgColor
: "bg-muted",
)}
>
{
<Logo
className={
settings.providers?.[providerKey]?.configured
? logoColor
: "text-muted-foreground"
}
/>
}
</div>
</div>
<CardTitle className="flex flex-row items-center gap-2">
{name}
{isCurrentProvider && (
<span
className={cn(
"h-2 w-2 rounded-full",
isProviderUnhealthy
? "bg-destructive"
: "bg-accent-emerald-foreground",
)}
aria-label={isProviderUnhealthy ? "Error" : "Active"}
/>
)}
</CardTitle>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col justify-end space-y-4">
<Button
variant={isProviderUnhealthy ? "default" : "outline"}
onClick={() => setDialogOpen(providerKey)}
>
{isProviderUnhealthy
? "Fix Setup"
: settings.providers?.[providerKey]?.configured
? "Edit Setup"
: "Configure"}
</Button>
</CardContent>
</Card>
);
})}
</div>
<AnthropicSettingsDialog
open={dialogOpen === "anthropic"}
setOpen={handleCloseDialog}
/>
<OpenAISettingsDialog
open={dialogOpen === "openai"}
setOpen={handleCloseDialog}
/>
<OllamaSettingsDialog
open={dialogOpen === "ollama"}
setOpen={handleCloseDialog}
/>
<WatsonxSettingsDialog
open={dialogOpen === "watsonx"}
setOpen={handleCloseDialog}
/>
</>
);
};
export default ModelProviders;

View file

@ -1,7 +1,7 @@
import { type ReactNode, useEffect } from "react";
import { Controller, useFormContext } from "react-hook-form";
import type { ModelOption } from "@/app/api/queries/useGetModelsQuery";
import { ModelSelector } from "@/app/onboarding/components/model-selector";
import { ModelSelector } from "@/app/onboarding/_components/model-selector";
import { LabelWrapper } from "@/components/label-wrapper";
interface ModelSelectorsProps {

View file

@ -0,0 +1,159 @@
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { useGetOllamaModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import OllamaLogo from "@/components/icons/ollama-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { useAuth } from "@/contexts/auth-context";
import {
OllamaSettingsForm,
type OllamaSettingsFormData,
} from "./ollama-settings-form";
const OllamaSettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const { data: settings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
const isOllamaConfigured = settings.providers?.ollama?.configured === true;
const methods = useForm<OllamaSettingsFormData>({
mode: "onSubmit",
defaultValues: {
endpoint: isOllamaConfigured
? settings.providers?.ollama?.endpoint
: "http://localhost:11434",
},
});
const { handleSubmit, watch } = methods;
const endpoint = watch("endpoint");
const { refetch: validateCredentials } = useGetOllamaModelsQuery(
{
endpoint: endpoint,
},
{
enabled: false,
},
);
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "ollama",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success(
"Ollama endpoint saved. Configure models in the Settings page.",
);
setOpen(false);
},
});
const onSubmit = async (data: OllamaSettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
// Validate endpoint by fetching models
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
if (result.isError) {
setValidationError(result.error);
return;
}
settingsMutation.mutate({
ollama_endpoint: data.endpoint,
});
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<OllamaLogo className="text-black" />
</div>
Ollama Setup
</DialogTitle>
</DialogHeader>
<OllamaSettingsForm
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default OllamaSettingsDialog;

View file

@ -0,0 +1,158 @@
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { useGetOpenAIModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import OpenAILogo from "@/components/icons/openai-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
OpenAISettingsForm,
type OpenAISettingsFormData,
} from "./openai-settings-form";
const OpenAISettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const methods = useForm<OpenAISettingsFormData>({
mode: "onSubmit",
defaultValues: {
apiKey: "",
},
});
const { handleSubmit, watch } = methods;
const apiKey = watch("apiKey");
const { refetch: validateCredentials } = useGetOpenAIModelsQuery(
{
apiKey: apiKey,
},
{
enabled: false,
},
);
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "openai",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success(
"OpenAI credentials saved. Configure models in the Settings page.",
);
setOpen(false);
},
});
const onSubmit = async (data: OpenAISettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
// Only validate if a new API key was entered
if (data.apiKey) {
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
if (result.isError) {
setValidationError(result.error);
return;
}
}
const payload: {
openai_api_key?: string;
} = {};
// Only include api_key if a value was entered
if (data.apiKey) {
payload.openai_api_key = data.apiKey;
}
// Submit the update
settingsMutation.mutate(payload);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<OpenAILogo className="text-black" />
</div>
OpenAI Setup
</DialogTitle>
</DialogHeader>
<OpenAISettingsForm
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default OpenAISettingsDialog;

View file

@ -0,0 +1,166 @@
import { useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "motion/react";
import { useState } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { toast } from "sonner";
import { useUpdateSettingsMutation } from "@/app/api/mutations/useUpdateSettingsMutation";
import { useGetIBMModelsQuery } from "@/app/api/queries/useGetModelsQuery";
import type { ProviderHealthResponse } from "@/app/api/queries/useProviderHealthQuery";
import IBMLogo from "@/components/icons/ibm-logo";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
WatsonxSettingsForm,
type WatsonxSettingsFormData,
} from "./watsonx-settings-form";
const WatsonxSettingsDialog = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (open: boolean) => void;
}) => {
const queryClient = useQueryClient();
const [isValidating, setIsValidating] = useState(false);
const [validationError, setValidationError] = useState<Error | null>(null);
const methods = useForm<WatsonxSettingsFormData>({
mode: "onSubmit",
defaultValues: {
endpoint: "https://us-south.ml.cloud.ibm.com",
apiKey: "",
projectId: "",
},
});
const { handleSubmit, watch } = methods;
const endpoint = watch("endpoint");
const apiKey = watch("apiKey");
const projectId = watch("projectId");
const { refetch: validateCredentials } = useGetIBMModelsQuery(
{
endpoint: endpoint,
apiKey: apiKey,
projectId: projectId,
},
{
enabled: false,
},
);
const settingsMutation = useUpdateSettingsMutation({
onSuccess: () => {
// Update provider health cache to healthy since backend validated the setup
const healthData: ProviderHealthResponse = {
status: "healthy",
message: "Provider is configured and working correctly",
provider: "watsonx",
};
queryClient.setQueryData(["provider", "health"], healthData);
toast.success(
"watsonx credentials saved. Configure models in the Settings page.",
);
setOpen(false);
},
});
const onSubmit = async (data: WatsonxSettingsFormData) => {
// Clear any previous validation errors
setValidationError(null);
// Validate credentials by fetching models
setIsValidating(true);
const result = await validateCredentials();
setIsValidating(false);
if (result.isError) {
setValidationError(result.error);
return;
}
const payload: {
watsonx_endpoint: string;
watsonx_api_key?: string;
watsonx_project_id: string;
} = {
watsonx_endpoint: data.endpoint,
watsonx_project_id: data.projectId,
};
// Only include api_key if a value was entered
if (data.apiKey) {
payload.watsonx_api_key = data.apiKey;
}
// Submit the update
settingsMutation.mutate(payload);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent autoFocus={false} className="max-w-2xl">
<FormProvider {...methods}>
<form onSubmit={handleSubmit(onSubmit)} className="grid gap-4">
<DialogHeader className="mb-2">
<DialogTitle className="flex items-center gap-3">
<div className="w-8 h-8 rounded flex items-center justify-center bg-white border">
<IBMLogo className="text-black" />
</div>
IBM watsonx.ai Setup
</DialogTitle>
</DialogHeader>
<WatsonxSettingsForm
modelsError={validationError}
isLoadingModels={isValidating}
/>
<AnimatePresence mode="wait">
{settingsMutation.isError && (
<motion.div
key="error"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
>
<p className="rounded-lg border border-destructive p-4">
{settingsMutation.error?.message}
</p>
</motion.div>
)}
</AnimatePresence>
<DialogFooter className="mt-4">
<Button
variant="outline"
type="button"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={settingsMutation.isPending || isValidating}
>
{settingsMutation.isPending
? "Saving..."
: isValidating
? "Validating..."
: "Save"}
</Button>
</DialogFooter>
</form>
</FormProvider>
</DialogContent>
</Dialog>
);
};
export default WatsonxSettingsDialog;

View file

@ -1,7 +1,7 @@
import { useFormContext, Controller } from "react-hook-form";
import { LabelWrapper } from "@/components/label-wrapper";
import { Input } from "@/components/ui/input";
import { ModelSelector } from "@/app/onboarding/components/model-selector";
import { ModelSelector } from "@/app/onboarding/_components/model-selector";
export interface WatsonxSettingsFormData {
endpoint: string;

View file

@ -0,0 +1,108 @@
import AnthropicLogo from "@/components/icons/anthropic-logo";
import IBMLogo from "@/components/icons/ibm-logo";
import OllamaLogo from "@/components/icons/ollama-logo";
import OpenAILogo from "@/components/icons/openai-logo";
export type ModelProvider = "openai" | "anthropic" | "ollama" | "watsonx";
export interface ModelOption {
value: string;
label: string;
}
// Helper function to get model logo based on provider or model name
export function getModelLogo(modelValue: string, provider?: ModelProvider) {
// First check by provider
if (provider === "openai") {
return <OpenAILogo className="w-4 h-4" />;
} else if (provider === "anthropic") {
return <AnthropicLogo className="w-4 h-4" />;
} else if (provider === "ollama") {
return <OllamaLogo className="w-4 h-4" />;
} else if (provider === "watsonx") {
return <IBMLogo className="w-4 h-4" />;
}
// Fallback to model name analysis
if (modelValue.includes("gpt") || modelValue.includes("text-embedding")) {
return <OpenAILogo className="w-4 h-4" />;
} else if (modelValue.includes("llama") || modelValue.includes("ollama")) {
return <OllamaLogo className="w-4 h-4" />;
} else if (
modelValue.includes("granite") ||
modelValue.includes("slate") ||
modelValue.includes("ibm")
) {
return <IBMLogo className="w-4 h-4" />;
}
return <OpenAILogo className="w-4 h-4" />; // Default to OpenAI logo
}
// Helper function to get fallback models by provider
export function getFallbackModels(provider: ModelProvider) {
switch (provider) {
case "openai":
return {
language: [
{ value: "gpt-4", label: "GPT-4" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
],
embedding: [
{ value: "text-embedding-ada-002", label: "text-embedding-ada-002" },
{ value: "text-embedding-3-small", label: "text-embedding-3-small" },
{ value: "text-embedding-3-large", label: "text-embedding-3-large" },
],
};
case "anthropic":
return {
language: [
{ value: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" },
{ value: "claude-opus-4-1-20250805", label: "Claude Opus 4.1" },
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
],
};
case "ollama":
return {
language: [
{ value: "llama2", label: "Llama 2" },
{ value: "llama2:13b", label: "Llama 2 13B" },
{ value: "codellama", label: "Code Llama" },
],
embedding: [
{ value: "mxbai-embed-large", label: "MxBai Embed Large" },
{ value: "nomic-embed-text", label: "Nomic Embed Text" },
],
};
case "watsonx":
return {
language: [
{
value: "meta-llama/llama-3-1-70b-instruct",
label: "Llama 3.1 70B Instruct",
},
{ value: "ibm/granite-13b-chat-v2", label: "Granite 13B Chat v2" },
],
embedding: [
{
value: "ibm/slate-125m-english-rtrvr",
label: "Slate 125M English Retriever",
},
],
};
default:
return {
language: [
{ value: "gpt-4", label: "GPT-4" },
{ value: "gpt-4-turbo", label: "GPT-4 Turbo" },
{ value: "gpt-3.5-turbo", label: "GPT-3.5 Turbo" },
],
embedding: [
{ value: "text-embedding-ada-002", label: "text-embedding-ada-002" },
{ value: "text-embedding-3-small", label: "text-embedding-3-small" },
{ value: "text-embedding-3-large", label: "text-embedding-3-large" },
],
};
}
}

File diff suppressed because it is too large Load diff

View file

@ -8,8 +8,8 @@ import {
useGetConversationsQuery,
} from "@/app/api/queries/useGetConversationsQuery";
import type { Settings } from "@/app/api/queries/useGetSettingsQuery";
import { OnboardingContent } from "@/app/onboarding/components/onboarding-content";
import { ProgressBar } from "@/app/onboarding/components/progress-bar";
import { OnboardingContent } from "@/app/onboarding/_components/onboarding-content";
import { ProgressBar } from "@/app/onboarding/_components/progress-bar";
import { AnimatedConditional } from "@/components/animated-conditional";
import { Header } from "@/components/header";
import { Navigation } from "@/components/navigation";

View file

@ -0,0 +1,88 @@
"use client";
import { FileText, Folder, Trash2 } from "lucide-react";
import GoogleDriveIcon from "@/components/icons/google-drive-logo";
import OneDriveIcon from "@/components/icons/one-drive-logo";
import SharePointIcon from "@/components/icons/share-point-logo";
import { Button } from "@/components/ui/button";
import type { CloudFile } from "./types";
interface FileItemProps {
provider: string;
file: CloudFile;
shouldDisableActions: boolean;
onRemove: (fileId: string) => void;
}
const getFileIcon = (mimeType: string) => {
if (mimeType.includes("folder")) {
return <Folder className="h-6 w-6" />;
}
return <FileText className="h-6 w-6" />;
};
const getMimeTypeLabel = (mimeType: string) => {
const typeMap: { [key: string]: string } = {
"application/vnd.google-apps.document": "Google Doc",
"application/vnd.google-apps.spreadsheet": "Google Sheet",
"application/vnd.google-apps.presentation": "Google Slides",
"application/vnd.google-apps.folder": "Folder",
"application/pdf": "PDF",
"text/plain": "Text",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document":
"Word Doc",
"application/vnd.openxmlformats-officedocument.presentationml.presentation":
"PowerPoint",
};
return typeMap[mimeType] || mimeType?.split("/").pop() || "Document";
};
const formatFileSize = (bytes?: number) => {
if (!bytes) return "";
const sizes = ["B", "KB", "MB", "GB", "TB"];
if (bytes === 0) return "0 B";
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / 1024 ** i).toFixed(1)} ${sizes[i]}`;
};
const getProviderIcon = (provider: string) => {
switch (provider) {
case "google_drive":
return <GoogleDriveIcon />;
case "onedrive":
return <OneDriveIcon />;
case "sharepoint":
return <SharePointIcon />;
default:
return <FileText className="h-6 w-6" />;
}
};
export const FileItem = ({ file, onRemove, provider }: FileItemProps) => (
<div
key={file.id}
className="flex items-center justify-between p-1.5 rounded-md text-xs"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{provider ? getProviderIcon(provider) : getFileIcon(file.mimeType)}
<span className="truncate font-medium text-sm mr-2">{file.name}</span>
<span className="text-sm text-muted-foreground">
{getMimeTypeLabel(file.mimeType)}
</span>
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-muted-foreground mr-4" title="file size">
{formatFileSize(file.size) || "—"}
</span>
<Button
className="text-muted-foreground hover:text-destructive"
size="icon"
variant="ghost"
onClick={() => onRemove(file.id)}
>
<Trash2 size={16} />
</Button>
</div>
</div>
);

View file

@ -0,0 +1,252 @@
"use client";
import { ChevronRight } from "lucide-react";
import { useEffect } from "react";
import {
useGetIBMModelsQuery,
useGetOllamaModelsQuery,
useGetOpenAIModelsQuery,
} from "@/app/api/queries/useGetModelsQuery";
import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery";
import type { ModelOption } from "@/app/onboarding/_components/model-selector";
import {
getFallbackModels,
type ModelProvider,
} from "@/app/settings/_helpers/model-helpers";
import { ModelSelectItems } from "@/app/settings/_helpers/model-select-item";
import { LabelWrapper } from "@/components/label-wrapper";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { NumberInput } from "@/components/ui/inputs/number-input";
import {
Select,
SelectContent,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useAuth } from "@/contexts/auth-context";
import type { IngestSettings as IngestSettingsType } from "./types";
interface IngestSettingsProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
settings?: IngestSettingsType;
onSettingsChange?: (settings: IngestSettingsType) => void;
}
export const IngestSettings = ({
isOpen,
onOpenChange,
settings,
onSettingsChange,
}: IngestSettingsProps) => {
const { isAuthenticated, isNoAuthMode } = useAuth();
// Fetch settings from API to get current embedding model
const { data: apiSettings = {} } = useGetSettingsQuery({
enabled: isAuthenticated || isNoAuthMode,
});
// Get the current provider from API settings
const currentProvider = (apiSettings.knowledge?.embedding_provider ||
"openai") as ModelProvider;
// Fetch available models based on provider
const { data: openaiModelsData } = useGetOpenAIModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "openai",
});
const { data: ollamaModelsData } = useGetOllamaModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "ollama",
});
const { data: ibmModelsData } = useGetIBMModelsQuery(undefined, {
enabled: (isAuthenticated || isNoAuthMode) && currentProvider === "watsonx",
});
// Select the appropriate models data based on provider
const modelsData =
currentProvider === "openai"
? openaiModelsData
: currentProvider === "ollama"
? ollamaModelsData
: currentProvider === "watsonx"
? ibmModelsData
: openaiModelsData;
// Get embedding model from API settings
const apiEmbeddingModel =
apiSettings.knowledge?.embedding_model ||
modelsData?.embedding_models?.find((m) => m.default)?.value ||
"text-embedding-3-small";
// Default settings - use API embedding model
const defaultSettings: IngestSettingsType = {
chunkSize: 1000,
chunkOverlap: 200,
ocr: false,
pictureDescriptions: false,
embeddingModel: apiEmbeddingModel,
};
// Use provided settings or defaults
const currentSettings = settings || defaultSettings;
// Update settings when API embedding model changes
useEffect(() => {
if (
apiEmbeddingModel &&
(!settings || settings.embeddingModel !== apiEmbeddingModel)
) {
onSettingsChange?.({
...currentSettings,
embeddingModel: apiEmbeddingModel,
});
}
}, [apiEmbeddingModel, settings, onSettingsChange, currentSettings]);
const handleSettingsChange = (newSettings: Partial<IngestSettingsType>) => {
const updatedSettings = { ...currentSettings, ...newSettings };
onSettingsChange?.(updatedSettings);
};
return (
<Collapsible
open={isOpen}
onOpenChange={onOpenChange}
className="border rounded-xl p-4 border-border"
>
<CollapsibleTrigger className="flex items-center gap-2 justify-between w-full -m-4 p-4 rounded-md transition-colors">
<div className="flex items-center gap-2">
<ChevronRight
className={`h-4 w-4 text-muted-foreground transition-transform duration-200 ${
isOpen ? "rotate-90" : ""
}`}
/>
<span className="text-sm font-medium">Ingest settings</span>
</div>
</CollapsibleTrigger>
<CollapsibleContent className="data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-up-2 data-[state=open]:slide-down-2">
<div className="mt-6">
{/* Embedding model selection */}
<LabelWrapper
helperText="Model used for knowledge ingest and retrieval"
id="embedding-model-select"
label="Embedding model"
>
<Select
disabled={false}
value={currentSettings.embeddingModel}
onValueChange={(value) =>
handleSettingsChange({ embeddingModel: value })
}
>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger id="embedding-model-select">
<SelectValue placeholder="Select an embedding model" />
</SelectTrigger>
</TooltipTrigger>
<TooltipContent>
Choose the embedding model for this upload
</TooltipContent>
</Tooltip>
<SelectContent>
<ModelSelectItems
models={modelsData?.embedding_models}
fallbackModels={
getFallbackModels(currentProvider)
.embedding as ModelOption[]
}
provider={currentProvider}
/>
</SelectContent>
</Select>
</LabelWrapper>
</div>
<div className="mt-6">
<div className="flex items-center gap-4 w-full mb-6">
<div className="w-full">
<NumberInput
id="chunk-size"
label="Chunk size"
value={currentSettings.chunkSize}
onChange={(value) => handleSettingsChange({ chunkSize: value })}
unit="characters"
/>
</div>
<div className="w-full">
<NumberInput
id="chunk-overlap"
label="Chunk overlap"
value={currentSettings.chunkOverlap}
onChange={(value) =>
handleSettingsChange({ chunkOverlap: value })
}
unit="characters"
/>
</div>
</div>
{/* <div className="flex gap-2 items-center justify-between">
<div>
<div className="text-sm font-semibold pb-2">Table Structure</div>
<div className="text-sm text-muted-foreground">
Capture table structure during ingest.
</div>
</div>
<Switch
id="table-structure"
checked={currentSettings.tableStructure}
onCheckedChange={(checked) =>
handleSettingsChange({ tableStructure: checked })
}
/>
</div> */}
<div className="flex items-center justify-between border-b pb-3 mb-3">
<div>
<div className="text-sm font-semibold pb-2">OCR</div>
<div className="text-sm text-muted-foreground">
Extracts text from images/PDFs. Ingest is slower when enabled.
</div>
</div>
<Switch
checked={currentSettings.ocr}
onCheckedChange={(checked) =>
handleSettingsChange({ ocr: checked })
}
/>
</div>
<div className="flex items-center justify-between">
<div>
<div className="text-sm pb-2 font-semibold">
Picture descriptions
</div>
<div className="text-sm text-muted-foreground">
Adds captions for images. Ingest is more expensive when enabled.
</div>
</div>
<Switch
checked={currentSettings.pictureDescriptions}
onCheckedChange={(checked) =>
handleSettingsChange({ pictureDescriptions: checked })
}
/>
</div>
</div>
</CollapsibleContent>
</Collapsible>
);
};

View file

@ -1,6 +1,6 @@
"use client";
import React from "react";
import { ReactNode, useState } from "react";
import {
Dialog,
DialogContent,
@ -8,77 +8,65 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { AlertTriangle } from "lucide-react";
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
interface ConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
description?: string;
trigger: ReactNode;
title: string;
description: ReactNode;
confirmText?: string;
cancelText?: string;
onConfirm: () => void | Promise<void>;
isLoading?: boolean;
variant?: "destructive" | "default";
onConfirm: (closeDialog: () => void) => void;
onCancel?: () => void;
variant?: "default" | "destructive" | "warning";
confirmIcon?: ReactNode | null;
}
export const DeleteConfirmationDialog: React.FC<ConfirmationDialogProps> = ({
open,
onOpenChange,
title = "Are you sure?",
description = "This action cannot be undone.",
confirmText = "Confirm",
export function ConfirmationDialog({
trigger,
title,
description,
confirmText = "Continue",
cancelText = "Cancel",
onConfirm,
isLoading = false,
variant = "destructive",
}) => {
const handleConfirm = async () => {
try {
await onConfirm();
} finally {
// Only close if not in loading state (let the parent handle this)
if (!isLoading) {
onOpenChange(false);
}
}
onCancel,
variant = "default",
confirmIcon = null,
}: ConfirmationDialogProps) {
const [open, setOpen] = useState(false);
const handleConfirm = () => {
const closeDialog = () => setOpen(false);
onConfirm(closeDialog);
};
const handleCancel = () => {
onCancel?.();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<div className="flex items-center gap-3">
{variant === "destructive" && (
<AlertTriangle className="h-6 w-6 text-destructive" />
)}
<DialogTitle>{title}</DialogTitle>
</div>
<DialogDescription>{description}</DialogDescription>
<DialogTitle className="mb-4">{title}</DialogTitle>
<DialogDescription className="text-left">
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
<Button variant="ghost" onClick={handleCancel} size="sm">
{cancelText}
</Button>
<Button
type="button"
variant={variant}
onClick={handleConfirm}
loading={isLoading}
disabled={isLoading}
>
<Button variant={variant} onClick={handleConfirm} size="sm">
{confirmText}
{confirmIcon}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
}

View file

@ -0,0 +1,84 @@
"use client";
import React from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "./ui/dialog";
import { Button } from "./ui/button";
import { AlertTriangle } from "lucide-react";
interface DeleteConfirmationDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
onConfirm: () => void | Promise<void>;
isLoading?: boolean;
variant?: "destructive" | "default";
}
export const DeleteConfirmationDialog: React.FC<DeleteConfirmationDialogProps> = ({
open,
onOpenChange,
title = "Are you sure?",
description = "This action cannot be undone.",
confirmText = "Confirm",
cancelText = "Cancel",
onConfirm,
isLoading = false,
variant = "destructive",
}) => {
const handleConfirm = async () => {
try {
await onConfirm();
} finally {
// Only close if not in loading state (let the parent handle this)
if (!isLoading) {
onOpenChange(false);
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<div className="flex items-center gap-3">
{variant === "destructive" && (
<AlertTriangle className="h-6 w-6 text-destructive" />
)}
<DialogTitle>{title}</DialogTitle>
</div>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
{cancelText}
</Button>
<Button
type="button"
variant={variant}
onClick={handleConfirm}
loading={isLoading}
disabled={isLoading}
>
{confirmText}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -2,154 +2,154 @@
import { AlertTriangle, Copy, ExternalLink } from "lucide-react";
import { useState } from "react";
import { useDoclingHealthQuery } from "@/app/api/queries/useDoclingHealthQuery";
import {
Banner,
BannerAction,
BannerIcon,
BannerTitle,
Banner,
BannerAction,
BannerIcon,
BannerTitle,
} from "@/components/ui/banner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { cn } from "@/lib/utils";
import { useDoclingHealthQuery } from "@/src/app/api/queries/useDoclingHealthQuery";
interface DoclingHealthBannerProps {
className?: string;
className?: string;
}
// DoclingSetupDialog component
interface DoclingSetupDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
className?: string;
}
function DoclingSetupDialog({
open,
onOpenChange,
className,
open,
onOpenChange,
className,
}: DoclingSetupDialogProps) {
const [copied, setCopied] = useState(false);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
const handleCopy = async () => {
await navigator.clipboard.writeText("uv run openrag");
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle>
<DialogDescription>Start docling-serve by running:</DialogDescription>
</DialogHeader>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className={cn("max-w-lg", className)}>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<AlertTriangle className="h-4 w-4 text-amber-600 dark:text-amber-400" />
docling-serve is stopped. Knowledge ingest is unavailable.
</DialogTitle>
<DialogDescription>Start docling-serve by running:</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted px-3 py-2.5 rounded-md text-sm font-mono">
uv run openrag
</code>
<Button
variant="ghost"
size="icon"
onClick={handleCopy}
className="shrink-0"
title={copied ? "Copied!" : "Copy to clipboard"}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<DialogDescription>
Then, select{" "}
<span className="font-semibold text-foreground">
Start All Services
</span>{" "}
in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>
<DialogDescription>
Then, select{" "}
<span className="font-semibold text-foreground">
Start All Services
</span>{" "}
in the TUI. Once docling-serve is running, refresh OpenRAG.
</DialogDescription>
</div>
<DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter>
<Button variant="default" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// Custom hook to check docling health status
export function useDoclingHealth() {
const { data: health, isLoading, isError } = useDoclingHealthQuery();
const { data: health, isLoading, isError } = useDoclingHealthQuery();
const isHealthy = health?.status === "healthy" && !isError;
// Only consider unhealthy if backend is up but docling is down
// Don't show banner if backend is unavailable
const isUnhealthy = health?.status === "unhealthy";
const isBackendUnavailable =
health?.status === "backend-unavailable" || isError;
const isHealthy = health?.status === "healthy" && !isError;
// Only consider unhealthy if backend is up but docling is down
// Don't show banner if backend is unavailable
const isUnhealthy = health?.status === "unhealthy";
const isBackendUnavailable =
health?.status === "backend-unavailable" || isError;
return {
health,
isLoading,
isError,
isHealthy,
isUnhealthy,
isBackendUnavailable,
};
return {
health,
isLoading,
isError,
isHealthy,
isUnhealthy,
isBackendUnavailable,
};
}
export function DoclingHealthBanner({ className }: DoclingHealthBannerProps) {
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false);
const { isLoading, isHealthy, isUnhealthy } = useDoclingHealth();
const [showDialog, setShowDialog] = useState(false);
// Only show banner when service is unhealthy
if (isLoading || isHealthy) {
return null;
}
// Only show banner when service is unhealthy
if (isLoading || isHealthy) {
return null;
}
if (isUnhealthy) {
return (
<>
<Banner
className={cn(
"bg-amber-50 dark:bg-amber-950 text-foreground border-accent-amber border-b w-full",
className,
)}
>
<BannerIcon
icon={AlertTriangle}
className="text-accent-amber-foreground"
/>
<BannerTitle className="font-medium">
docling-serve native service is stopped. Knowledge ingest is
unavailable.
</BannerTitle>
<BannerAction
onClick={() => setShowDialog(true)}
className="bg-foreground text-background hover:bg-primary/90"
>
Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
if (isUnhealthy) {
return (
<>
<Banner
className={cn(
"bg-amber-50 dark:bg-amber-950 text-foreground border-accent-amber border-b w-full",
className,
)}
>
<BannerIcon
icon={AlertTriangle}
className="text-accent-amber-foreground"
/>
<BannerTitle className="font-medium">
docling-serve native service is stopped. Knowledge ingest is
unavailable.
</BannerTitle>
<BannerAction
onClick={() => setShowDialog(true)}
className="bg-foreground text-background hover:bg-primary/90"
>
Setup Docling Serve
<ExternalLink className="h-3 w-3 ml-1" />
</BannerAction>
</Banner>
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
</>
);
}
<DoclingSetupDialog open={showDialog} onOpenChange={setShowDialog} />
</>
);
}
return null;
return null;
}

View file

@ -0,0 +1,65 @@
"use client";
import { Bell } from "lucide-react";
import Logo from "@/components/icons/openrag-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"
style={{ fontFamily: '"IBM Plex Mono", monospace' }}
>
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>
);
}

View file

@ -0,0 +1,225 @@
const AnimatedProcessingIcon = ({
className,
props,
}: {
className?: string;
props?: React.SVGProps<SVGSVGElement>;
}) => {
// CSS for the stepped animation states
const animationCSS = `
.state-1 { opacity: 1; animation: showState1 1.5s infinite steps(1, end); }
.state-2 { opacity: 0; animation: showState2 1.5s infinite steps(1, end); }
.state-3 { opacity: 0; animation: showState3 1.5s infinite steps(1, end); }
.state-4 { opacity: 0; animation: showState4 1.5s infinite steps(1, end); }
.state-5 { opacity: 0; animation: showState5 1.5s infinite steps(1, end); }
.state-6 { opacity: 0; animation: showState6 1.5s infinite steps(1, end); }
@keyframes showState1 {
0%, 16.66% { opacity: 1; }
16.67%, 100% { opacity: 0; }
}
@keyframes showState2 {
0%, 16.66% { opacity: 0; }
16.67%, 33.33% { opacity: 1; }
33.34%, 100% { opacity: 0; }
}
@keyframes showState3 {
0%, 33.33% { opacity: 0; }
33.34%, 50% { opacity: 1; }
50.01%, 100% { opacity: 0; }
}
@keyframes showState4 {
0%, 50% { opacity: 0; }
50.01%, 66.66% { opacity: 1; }
66.67%, 100% { opacity: 0; }
}
@keyframes showState5 {
0%, 66.66% { opacity: 0; }
66.67%, 83.33% { opacity: 1; }
83.34%, 100% { opacity: 0; }
}
@keyframes showState6 {
0%, 83.33% { opacity: 0; }
83.34%, 100% { opacity: 1; }
}
`;
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}
>
<title>Animated Processing Icon</title>
{/* Inject animation styles into the SVG's shadow */}
<style dangerouslySetInnerHTML={{ __html: animationCSS }} />
{/* State 1 */}
<g className="state-1">
<rect
x="-19.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
</g>
{/* State 2 */}
<g className="state-2">
<rect
x="-53.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 20.375H8V20.75H8.375V22.25H8V23H5V22.25H4.625V20.75H5V20.375H5.375V19.625H7.625V20.375Z"
fill="currentColor"
/>
<path d="M4.625 20H3.125V18.5H4.625V20Z" fill="currentColor" />
<path d="M9.875 20H8.375V18.5H9.875V20Z" fill="currentColor" />
<path d="M6.125 18.5H4.625V17H6.125V18.5Z" fill="currentColor" />
<path d="M8.375 18.5H6.875V17H8.375V18.5Z" fill="currentColor" />
</g>
{/* State 3 */}
<g className="state-3">
<rect
x="-87.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 20.375H8V20.75H8.375V22.25H8V23H5V22.25H4.625V20.75H5V20.375H5.375V19.625H7.625V20.375Z"
fill="currentColor"
/>
<path d="M4.625 20H3.125V18.5H4.625V20Z" fill="currentColor" />
<path d="M9.875 20H8.375V18.5H9.875V20Z" fill="currentColor" />
<path d="M6.125 18.5H4.625V17H6.125V18.5Z" fill="currentColor" />
<path d="M8.375 18.5H6.875V17H8.375V18.5Z" fill="currentColor" />
<path
d="M18.625 12.375H19V12.75H19.375V14.25H19V15H16V14.25H15.625V12.75H16V12.375H16.375V11.625H18.625V12.375Z"
fill="currentColor"
/>
<path d="M15.625 12H14.125V10.5H15.625V12Z" fill="currentColor" />
<path d="M20.875 12H19.375V10.5H20.875V12Z" fill="currentColor" />
<path d="M17.125 10.5H15.625V9H17.125V10.5Z" fill="currentColor" />
<path d="M19.375 10.5H17.875V9H19.375V10.5Z" fill="currentColor" />
</g>
{/* State 4 */}
<g className="state-4">
<rect
x="-122.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 4.375H8V4.75H8.375V6.25H8V7H5V6.25H4.625V4.75H5V4.375H5.375V3.625H7.625V4.375Z"
fill="currentColor"
/>
<path d="M4.625 4H3.125V2.5H4.625V4Z" fill="currentColor" />
<path d="M9.875 4H8.375V2.5H9.875V4Z" fill="currentColor" />
<path d="M6.125 2.5H4.625V1H6.125V2.5Z" fill="currentColor" />
<path d="M8.375 2.5H6.875V1H8.375V2.5Z" fill="currentColor" />
<g opacity="0.25">
<path
d="M7.625 20.375H8V20.75H8.375V22.25H8V23H5V22.25H4.625V20.75H5V20.375H5.375V19.625H7.625V20.375Z"
fill="currentColor"
/>
<path d="M4.625 20H3.125V18.5H4.625V20Z" fill="currentColor" />
<path d="M9.875 20H8.375V18.5H9.875V20Z" fill="currentColor" />
<path d="M6.125 18.5H4.625V17H6.125V18.5Z" fill="currentColor" />
<path d="M8.375 18.5H6.875V17H8.375V18.5Z" fill="currentColor" />
</g>
<path
d="M18.625 12.375H19V12.75H19.375V14.25H19V15H16V14.25H15.625V12.75H16V12.375H16.375V11.625H18.625V12.375Z"
fill="currentColor"
/>
<path d="M15.625 12H14.125V10.5H15.625V12Z" fill="currentColor" />
<path d="M20.875 12H19.375V10.5H20.875V12Z" fill="currentColor" />
<path d="M17.125 10.5H15.625V9H17.125V10.5Z" fill="currentColor" />
<path d="M19.375 10.5H17.875V9H19.375V10.5Z" fill="currentColor" />
</g>
{/* State 5 */}
<g className="state-5">
<rect
x="-156.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<path
d="M7.625 4.375H8V4.75H8.375V6.25H8V7H5V6.25H4.625V4.75H5V4.375H5.375V3.625H7.625V4.375Z"
fill="currentColor"
/>
<path d="M4.625 4H3.125V2.5H4.625V4Z" fill="currentColor" />
<path d="M9.875 4H8.375V2.5H9.875V4Z" fill="currentColor" />
<path d="M6.125 2.5H4.625V1H6.125V2.5Z" fill="currentColor" />
<path d="M8.375 2.5H6.875V1H8.375V2.5Z" fill="currentColor" />
<g opacity="0.25">
<path
d="M18.625 12.375H19V12.75H19.375V14.25H19V15H16V14.25H15.625V12.75H16V12.375H16.375V11.625H18.625V12.375Z"
fill="currentColor"
/>
<path d="M15.625 12H14.125V10.5H15.625V12Z" fill="currentColor" />
<path d="M20.875 12H19.375V10.5H20.875V12Z" fill="currentColor" />
<path d="M17.125 10.5H15.625V9H17.125V10.5Z" fill="currentColor" />
<path d="M19.375 10.5H17.875V9H19.375V10.5Z" fill="currentColor" />
</g>
</g>
{/* State 6 */}
<g className="state-6">
<rect
x="-190.5"
y="-19.5"
width="230.242"
height="63"
rx="4.5"
stroke="#9747FF"
strokeDasharray="10 5"
/>
<g opacity="0.25">
<path
d="M7.625 4.375H8V4.75H8.375V6.25H8V7H5V6.25H4.625V4.75H5V4.375H5.375V3.625H7.625V4.375Z"
fill="currentColor"
/>
<path d="M4.625 4H3.125V2.5H4.625V4Z" fill="currentColor" />
<path d="M9.875 4H8.375V2.5H9.875V4Z" fill="currentColor" />
<path d="M6.125 2.5H4.625V1H6.125V2.5Z" fill="currentColor" />
<path d="M8.375 2.5H6.875V1H8.375V2.5Z" fill="currentColor" />
</g>
</g>
</svg>
);
};
export default AnimatedProcessingIcon;

View file

@ -1,4 +1,4 @@
const AwsIcon = ({ className }: { className?: string }) => {
const AwsLogo = ({ className }: { className?: string }) => {
return (
<svg
width="16"
@ -8,6 +8,7 @@ const AwsIcon = ({ className }: { className?: string }) => {
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>AWS Logo</title>
<path
d="M4.50896 6.82059C4.50896 7.01818 4.53024 7.1784 4.56749 7.29588C4.61006 7.41337 4.66328 7.54154 4.73778 7.68039C4.76438 7.72311 4.77503 7.76584 4.77503 7.80322C4.77503 7.85662 4.7431 7.91003 4.67392 7.96343L4.33867 8.18772C4.29078 8.21977 4.24288 8.23579 4.20031 8.23579C4.1471 8.23579 4.09388 8.20909 4.04067 8.16102C3.96617 8.08092 3.90231 7.99547 3.8491 7.91003C3.79588 7.81924 3.74267 7.71777 3.68413 7.59494C3.26906 8.08626 2.74756 8.33191 2.11963 8.33191C1.67263 8.33191 1.3161 8.20375 1.05535 7.94741C0.794596 7.69107 0.66156 7.34929 0.66156 6.92206C0.66156 6.46813 0.821203 6.09964 1.14581 5.82194C1.47042 5.54424 1.90145 5.40539 2.44956 5.40539C2.63049 5.40539 2.81674 5.42141 3.01363 5.44811C3.21053 5.47482 3.41274 5.51754 3.6256 5.5656V5.17576C3.6256 4.76989 3.54045 4.48685 3.37549 4.3213C3.2052 4.15575 2.91785 4.07564 2.5081 4.07564C2.32185 4.07564 2.13028 4.097 1.93338 4.14506C1.73649 4.19313 1.54492 4.25187 1.35867 4.32664C1.27352 4.36402 1.20967 4.38538 1.17242 4.39606C1.13517 4.40674 1.10856 4.41208 1.08727 4.41208C1.01277 4.41208 0.975524 4.35868 0.975524 4.24653V3.98485C0.975524 3.89941 0.986167 3.83532 1.01277 3.79794C1.03938 3.76056 1.08727 3.72318 1.16177 3.68579C1.34802 3.58967 1.57152 3.50956 1.83227 3.44548C2.09302 3.37605 2.36974 3.34401 2.66242 3.34401C3.29567 3.34401 3.75863 3.4882 4.05663 3.77658C4.34931 4.06496 4.49831 4.50287 4.49831 5.09031V6.82059H4.50896ZM2.34845 7.63233C2.52406 7.63233 2.70499 7.60028 2.89656 7.5362C3.08813 7.47212 3.25842 7.35463 3.4021 7.19442C3.48724 7.09295 3.5511 6.9808 3.58303 6.85263C3.61495 6.72446 3.63624 6.56959 3.63624 6.38802V6.16372C3.48192 6.12634 3.31695 6.0943 3.14667 6.07294C2.97638 6.05158 2.81142 6.0409 2.64645 6.0409C2.28992 6.0409 2.02917 6.11032 1.85356 6.25451C1.67795 6.3987 1.59281 6.60163 1.59281 6.86865C1.59281 7.11965 1.65667 7.30656 1.7897 7.43473C1.91742 7.56824 2.10367 7.63233 2.34845 7.63233ZM6.62156 8.20909C6.52578 8.20909 6.46192 8.19307 6.41935 8.15568C6.37678 8.12364 6.33953 8.04888 6.3076 7.94741L5.05706 3.8193C5.02513 3.71249 5.00917 3.64307 5.00917 3.60569C5.00917 3.52024 5.05174 3.47218 5.13688 3.47218H5.65838C5.75949 3.47218 5.82867 3.4882 5.86592 3.52558C5.90849 3.55762 5.94042 3.63239 5.97235 3.73386L6.86635 7.26918L7.69649 3.73386C7.7231 3.62705 7.75503 3.55762 7.7976 3.52558C7.84017 3.49354 7.91467 3.47218 8.01046 3.47218H8.43617C8.53728 3.47218 8.60646 3.4882 8.64903 3.52558C8.6916 3.55762 8.72885 3.63239 8.75014 3.73386L9.59092 7.3119L10.5115 3.73386C10.5435 3.62705 10.5807 3.55762 10.618 3.52558C10.6605 3.49354 10.7297 3.47218 10.8255 3.47218H11.3204C11.4055 3.47218 11.4534 3.5149 11.4534 3.60569C11.4534 3.63239 11.4481 3.65909 11.4428 3.69113C11.4375 3.72318 11.4268 3.7659 11.4055 3.82464L10.1231 7.95275C10.0911 8.05956 10.0539 8.12898 10.0113 8.16102C9.96874 8.19307 9.89956 8.21443 9.8091 8.21443H9.35146C9.25035 8.21443 9.18117 8.19841 9.1386 8.16102C9.09603 8.12364 9.05878 8.05422 9.03749 7.94741L8.21267 4.50287L7.39317 7.94207C7.36656 8.04888 7.33463 8.1183 7.29206 8.15568C7.24949 8.19307 7.17499 8.20909 7.07921 8.20909H6.62156ZM13.4596 8.35328C13.1829 8.35328 12.9062 8.32123 12.6401 8.25715C12.374 8.19307 12.1665 8.12364 12.0281 8.04353C11.943 7.99547 11.8845 7.94207 11.8632 7.894C11.8419 7.84594 11.8312 7.79254 11.8312 7.74447V7.47212C11.8312 7.35997 11.8738 7.30656 11.9536 7.30656C11.9856 7.30656 12.0175 7.3119 12.0494 7.32259C12.0814 7.33327 12.1292 7.35463 12.1825 7.37599C12.3634 7.45609 12.5603 7.52018 12.7678 7.5629C12.9807 7.60562 13.1882 7.62699 13.4011 7.62699C13.7363 7.62699 13.9971 7.56824 14.178 7.45075C14.3589 7.33327 14.4547 7.16237 14.4547 6.94342C14.4547 6.79389 14.4068 6.67106 14.311 6.56959C14.2152 6.46813 14.0343 6.37734 13.7736 6.29189L13.002 6.05158C12.6135 5.92875 12.3261 5.74718 12.1505 5.50686C11.9749 5.27188 11.8845 5.0102 11.8845 4.73251C11.8845 4.50821 11.9324 4.31062 12.0281 4.13972C12.1239 3.96883 12.2516 3.8193 12.4113 3.70181C12.5709 3.57899 12.7519 3.4882 12.9647 3.42411C13.1776 3.36003 13.4011 3.33333 13.6352 3.33333C13.7523 3.33333 13.8747 3.33867 13.9917 3.35469C14.1141 3.37071 14.2259 3.39207 14.3376 3.41343C14.4441 3.44014 14.5452 3.46684 14.641 3.49888C14.7367 3.53092 14.8112 3.56296 14.8645 3.59501C14.939 3.63773 14.9922 3.68045 15.0241 3.72852C15.056 3.77124 15.072 3.82998 15.072 3.90475V4.15575C15.072 4.26789 15.0294 4.32664 14.9496 4.32664C14.907 4.32664 14.8379 4.30528 14.7474 4.26255C14.4441 4.1237 14.1035 4.05428 13.7257 4.05428C13.4224 4.05428 13.1829 4.10234 13.0179 4.20381C12.853 4.30528 12.7678 4.46015 12.7678 4.6791C12.7678 4.82863 12.821 4.9568 12.9275 5.05827C13.0339 5.15973 13.2308 5.2612 13.5128 5.35199L14.2685 5.5923C14.6516 5.71513 14.9283 5.88603 15.0933 6.10498C15.2582 6.32394 15.3381 6.57493 15.3381 6.85263C15.3381 7.08227 15.2902 7.29054 15.1997 7.47212C15.1039 7.65369 14.9762 7.8139 14.8112 7.94207C14.6463 8.07558 14.4494 8.1717 14.2206 8.24113C13.9811 8.31589 13.731 8.35328 13.4596 8.35328Z"
fill="currentColor"
@ -28,4 +29,4 @@ const AwsIcon = ({ className }: { className?: string }) => {
);
};
export default AwsIcon;
export default AwsLogo;

View file

@ -1,4 +1,4 @@
const GoogleDriveIcon = ({ className }: { className?: string }) => (
const GoogleDriveLogo = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
@ -7,6 +7,7 @@ const GoogleDriveIcon = ({ className }: { className?: string }) => (
fill="none"
className={className}
>
<title>Google Drive Logo</title>
<path
d="M2.03338 13.2368L2.75732 14.4872C2.90774 14.7504 3.12398 14.9573 3.37783 15.1077L5.9633 10.6325H0.792358C0.792358 10.9239 0.867572 11.2154 1.018 11.4786L2.03338 13.2368Z"
fill="#0066DA"
@ -34,4 +35,4 @@ const GoogleDriveIcon = ({ className }: { className?: string }) => (
</svg>
);
export default GoogleDriveIcon;
export default GoogleDriveLogo;

View file

@ -1,4 +1,4 @@
const OneDriveIcon = ({ className }: { className?: string }) => (
const OneDriveLogo = ({ className }: { className?: string }) => (
<svg
width="17"
height="12"
@ -7,6 +7,7 @@ const OneDriveIcon = ({ className }: { className?: string }) => (
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>OneDrive Logo</title>
<g clip-path="url(#clip0_3016_367)">
<path
d="M5.2316 2.32803C2.88332 2.3281 1.128 4.25034 0.99585 6.39175C1.07765 6.85315 1.34653 7.7643 1.76759 7.71751C2.29391 7.65902 3.61947 7.71751 4.75008 5.67068C5.57599 4.17546 7.27498 2.328 5.2316 2.32803Z"
@ -162,4 +163,4 @@ const OneDriveIcon = ({ className }: { className?: string }) => (
</svg>
);
export default OneDriveIcon;
export default OneDriveLogo;

View file

@ -1,4 +1,4 @@
export default function Logo(props: React.SVGProps<SVGSVGElement>) {
export default function OpenragLogo(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
width="50"

View file

@ -1,4 +1,4 @@
const SharePointIcon = ({ className }: { className?: string }) => (
const SharePointLogo = ({ className }: { className?: string }) => (
<svg
width="15"
height="16"
@ -7,6 +7,7 @@ const SharePointIcon = ({ className }: { className?: string }) => (
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<title>SharePoint Logo</title>
<g clip-path="url(#clip0_3016_409)">
<path
d="M6.1335 9.6C8.78446 9.6 10.9335 7.45096 10.9335 4.8C10.9335 2.14903 8.78446 0 6.1335 0C3.48254 0 1.3335 2.14903 1.3335 4.8C1.3335 7.45096 3.48254 9.6 6.1335 9.6Z"
@ -209,4 +210,4 @@ const SharePointIcon = ({ className }: { className?: string }) => (
</svg>
);
export default SharePointIcon;
export default SharePointLogo;

Some files were not shown because too many files have changed in this diff Show more