reorganize folder structure
This commit is contained in:
parent
173df0be99
commit
2d0838d5a8
160 changed files with 9816 additions and 10346 deletions
259
frontend/app/auth/callback/page.tsx
Normal file
259
frontend/app/auth/callback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
frontend/app/chat/_components/assistant-message.tsx
Normal file
95
frontend/app/chat/_components/assistant-message.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
339
frontend/app/chat/_components/chat-input.tsx
Normal file
339
frontend/app/chat/_components/chat-input.tsx
Normal 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 "{filterSearchTerm}"
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ChatInput.displayName = "ChatInput";
|
||||
237
frontend/app/chat/_components/function-calls.tsx
Normal file
237
frontend/app/chat/_components/function-calls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/app/chat/_components/selected-knowledge-filter.tsx
Normal file
33
frontend/app/chat/_components/selected-knowledge-filter.tsx
Normal 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
1367
frontend/app/chat/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 838 B After Width: | Height: | Size: 838 B |
423
frontend/app/knowledge/page.tsx
Normal file
423
frontend/app/knowledge/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
frontend/app/login/page.tsx
Normal file
73
frontend/app/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
frontend/app/onboarding/_components/animated-provider-steps.tsx
Normal file
214
frontend/app/onboarding/_components/animated-provider-steps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
frontend/app/onboarding/_components/anthropic-onboarding.tsx
Normal file
154
frontend/app/onboarding/_components/anthropic-onboarding.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
212
frontend/app/onboarding/_components/ibm-onboarding.tsx
Normal file
212
frontend/app/onboarding/_components/ibm-onboarding.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
173
frontend/app/onboarding/_components/ollama-onboarding.tsx
Normal file
173
frontend/app/onboarding/_components/ollama-onboarding.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
538
frontend/app/onboarding/_components/onboarding-card.tsx
Normal file
538
frontend/app/onboarding/_components/onboarding-card.tsx
Normal 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;
|
||||
159
frontend/app/onboarding/_components/onboarding-content.tsx
Normal file
159
frontend/app/onboarding/_components/onboarding-content.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
127
frontend/app/onboarding/_components/onboarding-step.tsx
Normal file
127
frontend/app/onboarding/_components/onboarding-step.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
168
frontend/app/onboarding/_components/openai-onboarding.tsx
Normal file
168
frontend/app/onboarding/_components/openai-onboarding.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
33
frontend/app/onboarding/_components/tab-trigger.tsx
Normal file
33
frontend/app/onboarding/_components/tab-trigger.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
158
frontend/app/settings/_components/anthropic-settings-dialog.tsx
Normal file
158
frontend/app/settings/_components/anthropic-settings-dialog.tsx
Normal 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;
|
||||
215
frontend/app/settings/_components/model-providers.tsx
Normal file
215
frontend/app/settings/_components/model-providers.tsx
Normal 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;
|
||||
|
|
@ -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 {
|
||||
159
frontend/app/settings/_components/ollama-settings-dialog.tsx
Normal file
159
frontend/app/settings/_components/ollama-settings-dialog.tsx
Normal 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;
|
||||
158
frontend/app/settings/_components/openai-settings-dialog.tsx
Normal file
158
frontend/app/settings/_components/openai-settings-dialog.tsx
Normal 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;
|
||||
166
frontend/app/settings/_components/watsonx-settings-dialog.tsx
Normal file
166
frontend/app/settings/_components/watsonx-settings-dialog.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
108
frontend/app/settings/_helpers/model-helpers.tsx
Normal file
108
frontend/app/settings/_helpers/model-helpers.tsx
Normal 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" },
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
1309
frontend/app/settings/page.tsx
Normal file
1309
frontend/app/settings/page.tsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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";
|
||||
88
frontend/components/cloud-picker/file-item.tsx
Normal file
88
frontend/components/cloud-picker/file-item.tsx
Normal 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>
|
||||
);
|
||||
252
frontend/components/cloud-picker/ingest-settings.tsx
Normal file
252
frontend/components/cloud-picker/ingest-settings.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
84
frontend/components/delete-confirmation-dialog.tsx
Normal file
84
frontend/components/delete-confirmation-dialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
65
frontend/components/header.tsx
Normal file
65
frontend/components/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
225
frontend/components/icons/animated-processing-icon.tsx
Normal file
225
frontend/components/icons/animated-processing-icon.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
export default function Logo(props: React.SVGProps<SVGSVGElement>) {
|
||||
export default function OpenragLogo(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="50"
|
||||
|
|
@ -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
Loading…
Add table
Reference in a new issue