break up chat and onboarding components
This commit is contained in:
parent
2454cc978e
commit
f51890a90f
8 changed files with 927 additions and 753 deletions
57
frontend/src/app/chat/components/assistant-message.tsx
Normal file
57
frontend/src/app/chat/components/assistant-message.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
import { Bot, GitBranch } from "lucide-react";
|
||||||
|
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
||||||
|
import { FunctionCalls } from "./function-calls";
|
||||||
|
import type { FunctionCall } from "../types";
|
||||||
|
|
||||||
|
interface AssistantMessageProps {
|
||||||
|
content: string;
|
||||||
|
functionCalls?: FunctionCall[];
|
||||||
|
messageIndex?: number;
|
||||||
|
expandedFunctionCalls: Set<string>;
|
||||||
|
onToggle: (functionCallId: string) => void;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
showForkButton?: boolean;
|
||||||
|
onFork?: (e: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AssistantMessage({
|
||||||
|
content,
|
||||||
|
functionCalls = [],
|
||||||
|
messageIndex,
|
||||||
|
expandedFunctionCalls,
|
||||||
|
onToggle,
|
||||||
|
isStreaming = false,
|
||||||
|
showForkButton = false,
|
||||||
|
onFork,
|
||||||
|
}: AssistantMessageProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
||||||
|
<Bot className="h-4 w-4 text-accent-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<FunctionCalls
|
||||||
|
functionCalls={functionCalls}
|
||||||
|
messageIndex={messageIndex}
|
||||||
|
expandedFunctionCalls={expandedFunctionCalls}
|
||||||
|
onToggle={onToggle}
|
||||||
|
/>
|
||||||
|
<MarkdownRenderer chatMessage={content} />
|
||||||
|
{isStreaming && (
|
||||||
|
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{showForkButton && onFork && (
|
||||||
|
<div className="flex-shrink-0 ml-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
284
frontend/src/app/chat/components/chat-input.tsx
Normal file
284
frontend/src/app/chat/components/chat-input.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import { Check, Funnel, Loader2, Plus, X } from "lucide-react";
|
||||||
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
|
import { forwardRef, useImperativeHandle, useRef } from "react";
|
||||||
|
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverAnchor,
|
||||||
|
PopoverContent,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import type { KnowledgeFilterData } from "../types";
|
||||||
|
import { FilterColor } from "@/components/filter-icon-popover";
|
||||||
|
|
||||||
|
export interface ChatInputHandle {
|
||||||
|
focusInput: () => void;
|
||||||
|
clickFileInput: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChatInputProps {
|
||||||
|
input: string;
|
||||||
|
loading: boolean;
|
||||||
|
isUploading: boolean;
|
||||||
|
selectedFilter: KnowledgeFilterData | null;
|
||||||
|
isFilterHighlighted: boolean;
|
||||||
|
isFilterDropdownOpen: boolean;
|
||||||
|
availableFilters: KnowledgeFilterData[];
|
||||||
|
filterSearchTerm: string;
|
||||||
|
selectedFilterIndex: number;
|
||||||
|
anchorPosition: { x: number; y: number } | null;
|
||||||
|
textareaHeight: number;
|
||||||
|
parsedFilterData: { color?: FilterColor } | null;
|
||||||
|
onSubmit: (e: React.FormEvent) => void;
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||||
|
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
|
||||||
|
onHeightChange: (height: number) => void;
|
||||||
|
onFilterSelect: (filter: KnowledgeFilterData | null) => void;
|
||||||
|
onAtClick: () => void;
|
||||||
|
onFilePickerChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
onFilePickerClick: () => void;
|
||||||
|
setSelectedFilter: (filter: KnowledgeFilterData | null) => void;
|
||||||
|
setIsFilterHighlighted: (highlighted: boolean) => void;
|
||||||
|
setIsFilterDropdownOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>((
|
||||||
|
{
|
||||||
|
input,
|
||||||
|
loading,
|
||||||
|
isUploading,
|
||||||
|
selectedFilter,
|
||||||
|
isFilterHighlighted,
|
||||||
|
isFilterDropdownOpen,
|
||||||
|
availableFilters,
|
||||||
|
filterSearchTerm,
|
||||||
|
selectedFilterIndex,
|
||||||
|
anchorPosition,
|
||||||
|
textareaHeight,
|
||||||
|
parsedFilterData,
|
||||||
|
onSubmit,
|
||||||
|
onChange,
|
||||||
|
onKeyDown,
|
||||||
|
onHeightChange,
|
||||||
|
onFilterSelect,
|
||||||
|
onAtClick,
|
||||||
|
onFilePickerChange,
|
||||||
|
onFilePickerClick,
|
||||||
|
setSelectedFilter,
|
||||||
|
setIsFilterHighlighted,
|
||||||
|
setIsFilterDropdownOpen,
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
focusInput: () => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
},
|
||||||
|
clickFileInput: () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pb-8 pt-4 flex px-6">
|
||||||
|
<div className="w-full">
|
||||||
|
<form onSubmit={onSubmit} className="relative">
|
||||||
|
<div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring">
|
||||||
|
{selectedFilter && (
|
||||||
|
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
@filter:{selectedFilter.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedFilter(null);
|
||||||
|
setIsFilterHighlighted(false);
|
||||||
|
}}
|
||||||
|
className="ml-1 rounded-full p-0.5"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
style={{ height: `${textareaHeight + 60}px` }}
|
||||||
|
>
|
||||||
|
<TextareaAutosize
|
||||||
|
ref={inputRef}
|
||||||
|
value={input}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
onHeightChange={onHeightChange}
|
||||||
|
maxRows={7}
|
||||||
|
minRows={2}
|
||||||
|
placeholder="Type to ask a question..."
|
||||||
|
disabled={loading}
|
||||||
|
className={`w-full bg-transparent px-4 ${
|
||||||
|
selectedFilter ? "pt-2" : "pt-4"
|
||||||
|
} focus-visible:outline-none resize-none`}
|
||||||
|
rows={2}
|
||||||
|
/>
|
||||||
|
{/* Safe area at bottom for buttons */}
|
||||||
|
<div
|
||||||
|
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
|
||||||
|
style={{ height: "60px" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
onChange={onFilePickerChange}
|
||||||
|
className="hidden"
|
||||||
|
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="iconSm"
|
||||||
|
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
||||||
|
onMouseDown={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
onClick={onAtClick}
|
||||||
|
data-filter-button
|
||||||
|
>
|
||||||
|
<Funnel className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Popover
|
||||||
|
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>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="iconSm"
|
||||||
|
onClick={onFilePickerClick}
|
||||||
|
disabled={isUploading}
|
||||||
|
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={!input.trim() || loading}
|
||||||
|
className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ChatInput.displayName = "ChatInput";
|
||||||
242
frontend/src/app/chat/components/function-calls.tsx
Normal file
242
frontend/src/app/chat/components/function-calls.tsx
Normal file
|
|
@ -0,0 +1,242 @@
|
||||||
|
import { ChevronDown, ChevronRight, Settings } from "lucide-react";
|
||||||
|
import type { FunctionCall } from "../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>
|
||||||
|
);
|
||||||
|
}
|
||||||
31
frontend/src/app/chat/components/user-message.tsx
Normal file
31
frontend/src/app/chat/components/user-message.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { User } from "lucide-react";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
import { useAuth } from "@/contexts/auth-context";
|
||||||
|
|
||||||
|
interface UserMessageProps {
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserMessage({ content }: UserMessageProps) {
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Avatar className="w-8 h-8 flex-shrink-0 select-none">
|
||||||
|
<AvatarImage draggable={false} src={user?.picture} alt={user?.name} />
|
||||||
|
<AvatarFallback className="text-sm bg-primary/20 text-primary">
|
||||||
|
{user?.name ? (
|
||||||
|
user.name.charAt(0).toUpperCase()
|
||||||
|
) : (
|
||||||
|
<User className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
||||||
|
{content}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,98 +1,31 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { Bot, Loader2, Zap } from "lucide-react";
|
||||||
Bot,
|
|
||||||
Check,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Funnel,
|
|
||||||
GitBranch,
|
|
||||||
Loader2,
|
|
||||||
Plus,
|
|
||||||
Settings,
|
|
||||||
User,
|
|
||||||
X,
|
|
||||||
Zap,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
|
||||||
import { filterAccentClasses } from "@/components/knowledge-filter-panel";
|
|
||||||
import { MarkdownRenderer } from "@/components/markdown-renderer";
|
|
||||||
import { ProtectedRoute } from "@/components/protected-route";
|
import { ProtectedRoute } from "@/components/protected-route";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Popover,
|
|
||||||
PopoverAnchor,
|
|
||||||
PopoverContent,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import { useAuth } from "@/contexts/auth-context";
|
|
||||||
import { type EndpointType, useChat } from "@/contexts/chat-context";
|
import { type EndpointType, useChat } from "@/contexts/chat-context";
|
||||||
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
import { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
|
||||||
import { useTask } from "@/contexts/task-context";
|
import { useTask } from "@/contexts/task-context";
|
||||||
import { useLoadingStore } from "@/stores/loadingStore";
|
import { useLoadingStore } from "@/stores/loadingStore";
|
||||||
import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery";
|
import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery";
|
||||||
import Nudges from "./nudges";
|
import Nudges from "./nudges";
|
||||||
|
import { UserMessage } from "./components/user-message";
|
||||||
interface Message {
|
import { AssistantMessage } from "./components/assistant-message";
|
||||||
role: "user" | "assistant";
|
import { ChatInput, type ChatInputHandle } from "./components/chat-input";
|
||||||
content: string;
|
import { Button } from "@/components/ui/button";
|
||||||
timestamp: Date;
|
import type {
|
||||||
functionCalls?: FunctionCall[];
|
Message,
|
||||||
isStreaming?: boolean;
|
FunctionCall,
|
||||||
}
|
ToolCallResult,
|
||||||
|
SelectedFilters,
|
||||||
interface FunctionCall {
|
KnowledgeFilterData,
|
||||||
name: string;
|
RequestBody,
|
||||||
arguments?: Record<string, unknown>;
|
} from "./types";
|
||||||
result?: Record<string, unknown> | ToolCallResult[];
|
|
||||||
status: "pending" | "completed" | "error";
|
|
||||||
argumentsString?: string;
|
|
||||||
id?: string;
|
|
||||||
type?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ToolCallResult {
|
|
||||||
text_key?: string;
|
|
||||||
data?: {
|
|
||||||
file_path?: string;
|
|
||||||
text?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
};
|
|
||||||
default_value?: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SelectedFilters {
|
|
||||||
data_sources: string[];
|
|
||||||
document_types: string[];
|
|
||||||
owners: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KnowledgeFilterData {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
query_data: string;
|
|
||||||
owner: string;
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RequestBody {
|
|
||||||
prompt: string;
|
|
||||||
stream?: boolean;
|
|
||||||
previous_response_id?: string;
|
|
||||||
filters?: SelectedFilters;
|
|
||||||
limit?: number;
|
|
||||||
scoreThreshold?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChatPage() {
|
function ChatPage() {
|
||||||
const isDebugMode =
|
const isDebugMode =
|
||||||
process.env.NODE_ENV === "development" ||
|
process.env.NODE_ENV === "development" ||
|
||||||
process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true";
|
process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true";
|
||||||
const { user } = useAuth();
|
|
||||||
const {
|
const {
|
||||||
endpoint,
|
endpoint,
|
||||||
setEndpoint,
|
setEndpoint,
|
||||||
|
|
@ -143,8 +76,7 @@ function ChatPage() {
|
||||||
y: number;
|
y: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const chatInputRef = useRef<ChatInputHandle>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const streamAbortRef = useRef<AbortController | null>(null);
|
const streamAbortRef = useRef<AbortController | null>(null);
|
||||||
const streamIdRef = useRef(0);
|
const streamIdRef = useRef(0);
|
||||||
const lastLoadedConversationRef = useRef<string | null>(null);
|
const lastLoadedConversationRef = useRef<string | null>(null);
|
||||||
|
|
@ -337,7 +269,7 @@ function ChatPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilePickerClick = () => {
|
const handleFilePickerClick = () => {
|
||||||
fileInputRef.current?.click();
|
chatInputRef.current?.clickFileInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
|
@ -345,10 +277,6 @@ function ChatPage() {
|
||||||
if (files && files.length > 0) {
|
if (files && files.length > 0) {
|
||||||
handleFileUpload(files[0]);
|
handleFileUpload(files[0]);
|
||||||
}
|
}
|
||||||
// Reset the input so the same file can be selected again
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadAvailableFilters = async () => {
|
const loadAvailableFilters = async () => {
|
||||||
|
|
@ -412,7 +340,7 @@ function ChatPage() {
|
||||||
|
|
||||||
// Auto-focus the input on component mount
|
// Auto-focus the input on component mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
inputRef.current?.focus();
|
chatInputRef.current?.focusInput();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Explicitly handle external new conversation trigger
|
// Explicitly handle external new conversation trigger
|
||||||
|
|
@ -439,7 +367,7 @@ function ChatPage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFocusInput = () => {
|
const handleFocusInput = () => {
|
||||||
inputRef.current?.focus();
|
chatInputRef.current?.focusInput();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("newConversation", handleNewConversation);
|
window.addEventListener("newConversation", handleNewConversation);
|
||||||
|
|
@ -1651,236 +1579,6 @@ function ChatPage() {
|
||||||
// This new forked conversation will get its own response_id when the user sends the next message
|
// This new forked conversation will get its own response_id when the user sends the next message
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFunctionCalls = (
|
|
||||||
functionCalls: FunctionCall[],
|
|
||||||
messageIndex?: number
|
|
||||||
) => {
|
|
||||||
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={() => toggleFunctionCall(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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSuggestionClick = (suggestion: string) => {
|
const handleSuggestionClick = (suggestion: string) => {
|
||||||
handleSendMessage(suggestion);
|
handleSendMessage(suggestion);
|
||||||
|
|
@ -1915,7 +1613,7 @@ function ChatPage() {
|
||||||
setDropdownDismissed(true);
|
setDropdownDismissed(true);
|
||||||
|
|
||||||
// Keep focus on the textarea so user can continue typing normally
|
// Keep focus on the textarea so user can continue typing normally
|
||||||
inputRef.current?.focus();
|
chatInputRef.current?.focusInput();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2121,74 +1819,33 @@ function ChatPage() {
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<div key={index} className="space-y-6 group">
|
<div key={index} className="space-y-6 group">
|
||||||
{message.role === "user" && (
|
{message.role === "user" && (
|
||||||
<div className="flex gap-3">
|
<UserMessage content={message.content} />
|
||||||
<Avatar className="w-8 h-8 flex-shrink-0 select-none">
|
|
||||||
<AvatarImage
|
|
||||||
draggable={false}
|
|
||||||
src={user?.picture}
|
|
||||||
alt={user?.name}
|
|
||||||
/>
|
|
||||||
<AvatarFallback className="text-sm bg-primary/20 text-primary">
|
|
||||||
{user?.name ? (
|
|
||||||
user.name.charAt(0).toUpperCase()
|
|
||||||
) : (
|
|
||||||
<User className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-foreground whitespace-pre-wrap break-words overflow-wrap-anywhere">
|
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{message.role === "assistant" && (
|
{message.role === "assistant" && (
|
||||||
<div className="flex gap-3">
|
<AssistantMessage
|
||||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0 select-none">
|
content={message.content}
|
||||||
<Bot className="h-4 w-4 text-accent-foreground" />
|
functionCalls={message.functionCalls}
|
||||||
</div>
|
messageIndex={index}
|
||||||
<div className="flex-1 min-w-0">
|
expandedFunctionCalls={expandedFunctionCalls}
|
||||||
{renderFunctionCalls(
|
onToggle={toggleFunctionCall}
|
||||||
message.functionCalls || [],
|
showForkButton={endpoint === "chat"}
|
||||||
index
|
onFork={e => handleForkConversation(index, e)}
|
||||||
)}
|
/>
|
||||||
<MarkdownRenderer chatMessage={message.content} />
|
|
||||||
</div>
|
|
||||||
{endpoint === "chat" && (
|
|
||||||
<div className="flex-shrink-0 ml-2">
|
|
||||||
<button
|
|
||||||
onClick={e => handleForkConversation(index, e)}
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Streaming Message Display */}
|
{/* Streaming Message Display */}
|
||||||
{streamingMessage && (
|
{streamingMessage && (
|
||||||
<div className="flex gap-3">
|
<AssistantMessage
|
||||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
|
content={streamingMessage.content}
|
||||||
<Bot className="h-4 w-4 text-accent-foreground" />
|
functionCalls={streamingMessage.functionCalls}
|
||||||
</div>
|
messageIndex={messages.length}
|
||||||
<div className="flex-1">
|
expandedFunctionCalls={expandedFunctionCalls}
|
||||||
{renderFunctionCalls(
|
onToggle={toggleFunctionCall}
|
||||||
streamingMessage.functionCalls,
|
isStreaming
|
||||||
messages.length
|
/>
|
||||||
)}
|
|
||||||
<MarkdownRenderer
|
|
||||||
chatMessage={streamingMessage.content}
|
|
||||||
/>
|
|
||||||
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Loading animation - shows immediately after user submits */}
|
{/* Loading animation - shows immediately after user submits */}
|
||||||
|
|
@ -2223,201 +1880,32 @@ function ChatPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Input Area - Fixed at bottom */}
|
{/* Input Area - Fixed at bottom */}
|
||||||
<div className="pb-8 pt-4 flex px-6">
|
<ChatInput
|
||||||
<div className="w-full">
|
ref={chatInputRef}
|
||||||
<form onSubmit={handleSubmit} className="relative">
|
input={input}
|
||||||
<div className="relative w-full bg-muted/20 rounded-lg border border-border/50 focus-within:ring-1 focus-within:ring-ring">
|
loading={loading}
|
||||||
{selectedFilter && (
|
isUploading={isUploading}
|
||||||
<div className="flex items-center gap-2 px-4 pt-3 pb-1">
|
selectedFilter={selectedFilter}
|
||||||
<span
|
isFilterHighlighted={isFilterHighlighted}
|
||||||
className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium transition-colors ${
|
isFilterDropdownOpen={isFilterDropdownOpen}
|
||||||
filterAccentClasses[parsedFilterData?.color || "zinc"]
|
availableFilters={availableFilters}
|
||||||
}`}
|
filterSearchTerm={filterSearchTerm}
|
||||||
>
|
selectedFilterIndex={selectedFilterIndex}
|
||||||
@filter:{selectedFilter.name}
|
anchorPosition={anchorPosition}
|
||||||
<button
|
textareaHeight={textareaHeight}
|
||||||
type="button"
|
parsedFilterData={parsedFilterData}
|
||||||
onClick={() => {
|
onSubmit={handleSubmit}
|
||||||
setSelectedFilter(null);
|
onChange={onChange}
|
||||||
setIsFilterHighlighted(false);
|
onKeyDown={handleKeyDown}
|
||||||
}}
|
onHeightChange={height => setTextareaHeight(height)}
|
||||||
className="ml-1 rounded-full p-0.5"
|
onFilterSelect={handleFilterSelect}
|
||||||
>
|
onAtClick={onAtClick}
|
||||||
<X className="h-3 w-3" />
|
onFilePickerChange={handleFilePickerChange}
|
||||||
</button>
|
onFilePickerClick={handleFilePickerClick}
|
||||||
</span>
|
setSelectedFilter={setSelectedFilter}
|
||||||
</div>
|
setIsFilterHighlighted={setIsFilterHighlighted}
|
||||||
)}
|
setIsFilterDropdownOpen={setIsFilterDropdownOpen}
|
||||||
<div
|
/>
|
||||||
className="relative"
|
|
||||||
style={{ height: `${textareaHeight + 60}px` }}
|
|
||||||
>
|
|
||||||
<TextareaAutosize
|
|
||||||
ref={inputRef}
|
|
||||||
value={input}
|
|
||||||
onChange={onChange}
|
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
onHeightChange={height => setTextareaHeight(height)}
|
|
||||||
maxRows={7}
|
|
||||||
minRows={2}
|
|
||||||
placeholder="Type to ask a question..."
|
|
||||||
disabled={loading}
|
|
||||||
className={`w-full bg-transparent px-4 ${
|
|
||||||
selectedFilter ? "pt-2" : "pt-4"
|
|
||||||
} focus-visible:outline-none resize-none`}
|
|
||||||
rows={2}
|
|
||||||
/>
|
|
||||||
{/* Safe area at bottom for buttons */}
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-0 right-0 bg-transparent pointer-events-none"
|
|
||||||
style={{ height: "60px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
onChange={handleFilePickerChange}
|
|
||||||
className="hidden"
|
|
||||||
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="iconSm"
|
|
||||||
className="absolute bottom-3 left-3 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
|
||||||
onMouseDown={e => {
|
|
||||||
e.preventDefault();
|
|
||||||
}}
|
|
||||||
onClick={onAtClick}
|
|
||||||
data-filter-button
|
|
||||||
>
|
|
||||||
<Funnel className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Popover
|
|
||||||
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={() => handleFilterSelect(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={() => handleFilterSelect(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>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="iconSm"
|
|
||||||
onClick={handleFilePickerClick}
|
|
||||||
disabled={isUploading}
|
|
||||||
className="absolute bottom-3 left-12 h-8 w-8 p-0 rounded-full hover:bg-muted/50"
|
|
||||||
>
|
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
disabled={!input.trim() || loading}
|
|
||||||
className="absolute bottom-3 right-3 rounded-lg h-10 px-4"
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : "Send"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
53
frontend/src/app/chat/types.ts
Normal file
53
frontend/src/app/chat/types.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
export interface Message {
|
||||||
|
role: "user" | "assistant";
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
functionCalls?: FunctionCall[];
|
||||||
|
isStreaming?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FunctionCall {
|
||||||
|
name: string;
|
||||||
|
arguments?: Record<string, unknown>;
|
||||||
|
result?: Record<string, unknown> | ToolCallResult[];
|
||||||
|
status: "pending" | "completed" | "error";
|
||||||
|
argumentsString?: string;
|
||||||
|
id?: string;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallResult {
|
||||||
|
text_key?: string;
|
||||||
|
data?: {
|
||||||
|
file_path?: string;
|
||||||
|
text?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
default_value?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectedFilters {
|
||||||
|
data_sources: string[];
|
||||||
|
document_types: string[];
|
||||||
|
owners: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KnowledgeFilterData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
query_data: string;
|
||||||
|
owner: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestBody {
|
||||||
|
prompt: string;
|
||||||
|
stream?: boolean;
|
||||||
|
previous_response_id?: string;
|
||||||
|
filters?: SelectedFilters;
|
||||||
|
limit?: number;
|
||||||
|
scoreThreshold?: number;
|
||||||
|
}
|
||||||
195
frontend/src/app/onboarding/components/onboarding-card.tsx
Normal file
195
frontend/src/app/onboarding/components/onboarding-card.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
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 IBMLogo from "@/components/logo/ibm-logo";
|
||||||
|
import OllamaLogo from "@/components/logo/ollama-logo";
|
||||||
|
import OpenAILogo from "@/components/logo/openai-logo";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { IBMOnboarding } from "./ibm-onboarding";
|
||||||
|
import { OllamaOnboarding } from "./ollama-onboarding";
|
||||||
|
import { OpenAIOnboarding } from "./openai-onboarding";
|
||||||
|
|
||||||
|
const OnboardingCard = ({
|
||||||
|
isDoclingHealthy,
|
||||||
|
boarderless = false,
|
||||||
|
}: {
|
||||||
|
isDoclingHealthy: boolean;
|
||||||
|
boarderless?: boolean;
|
||||||
|
}) => {
|
||||||
|
const { data: settingsDb, isLoading: isSettingsLoading } =
|
||||||
|
useGetSettingsQuery();
|
||||||
|
|
||||||
|
const redirect = "/";
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
// Redirect if already authenticated or in no-auth mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isSettingsLoading && settingsDb && settingsDb.edited) {
|
||||||
|
router.push(redirect);
|
||||||
|
}
|
||||||
|
}, [isSettingsLoading, settingsDb, router]);
|
||||||
|
|
||||||
|
|
||||||
|
const [modelProvider, setModelProvider] = useState<string>("openai");
|
||||||
|
|
||||||
|
const [sampleDataset, setSampleDataset] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const handleSetModelProvider = (provider: string) => {
|
||||||
|
setModelProvider(provider);
|
||||||
|
setSettings({
|
||||||
|
model_provider: provider,
|
||||||
|
embedding_model: "",
|
||||||
|
llm_model: "",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const [settings, setSettings] = useState<OnboardingVariables>({
|
||||||
|
model_provider: modelProvider,
|
||||||
|
embedding_model: "",
|
||||||
|
llm_model: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const onboardingMutation = useOnboardingMutation({
|
||||||
|
onSuccess: (data) => {
|
||||||
|
console.log("Onboarding completed successfully", data);
|
||||||
|
router.push(redirect);
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
toast.error("Failed to complete onboarding", {
|
||||||
|
description: error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
if (
|
||||||
|
!settings.model_provider ||
|
||||||
|
!settings.llm_model ||
|
||||||
|
!settings.embedding_model
|
||||||
|
) {
|
||||||
|
toast.error("Please complete all required fields");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare onboarding data
|
||||||
|
const onboardingData: OnboardingVariables = {
|
||||||
|
model_provider: settings.model_provider,
|
||||||
|
llm_model: settings.llm_model,
|
||||||
|
embedding_model: settings.embedding_model,
|
||||||
|
sample_data: sampleDataset,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add API key if available
|
||||||
|
if (settings.api_key) {
|
||||||
|
onboardingData.api_key = settings.api_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add endpoint if available
|
||||||
|
if (settings.endpoint) {
|
||||||
|
onboardingData.endpoint = settings.endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add project_id if available
|
||||||
|
if (settings.project_id) {
|
||||||
|
onboardingData.project_id = settings.project_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
onboardingMutation.mutate(onboardingData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const isComplete = !!settings.llm_model && !!settings.embedding_model && isDoclingHealthy;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`w-full max-w-[600px] ${boarderless ? "border-none" : ""}`}>
|
||||||
|
<Tabs
|
||||||
|
defaultValue={modelProvider}
|
||||||
|
onValueChange={handleSetModelProvider}
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="openai">
|
||||||
|
<OpenAILogo className="w-4 h-4" />
|
||||||
|
OpenAI
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="watsonx">
|
||||||
|
<IBMLogo className="w-4 h-4" />
|
||||||
|
IBM watsonx.ai
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ollama">
|
||||||
|
<OllamaLogo className="w-4 h-4" />
|
||||||
|
Ollama
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<TabsContent value="openai">
|
||||||
|
<OpenAIOnboarding
|
||||||
|
setSettings={setSettings}
|
||||||
|
sampleDataset={sampleDataset}
|
||||||
|
setSampleDataset={setSampleDataset}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="watsonx">
|
||||||
|
<IBMOnboarding
|
||||||
|
setSettings={setSettings}
|
||||||
|
sampleDataset={sampleDataset}
|
||||||
|
setSampleDataset={setSampleDataset}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="ollama">
|
||||||
|
<OllamaOnboarding
|
||||||
|
setSettings={setSettings}
|
||||||
|
sampleDataset={sampleDataset}
|
||||||
|
setSampleDataset={setSampleDataset}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</CardContent>
|
||||||
|
</Tabs>
|
||||||
|
<CardFooter className="flex justify-end">
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleComplete}
|
||||||
|
disabled={!isComplete}
|
||||||
|
loading={onboardingMutation.isPending}
|
||||||
|
>
|
||||||
|
<span className="select-none">Complete</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TooltipTrigger>
|
||||||
|
{!isComplete && (
|
||||||
|
<TooltipContent>
|
||||||
|
{!!settings.llm_model && !!settings.embedding_model && !isDoclingHealthy
|
||||||
|
? "docling-serve must be running to continue"
|
||||||
|
: "Please fill in all required fields"}
|
||||||
|
</TooltipContent>
|
||||||
|
)}
|
||||||
|
</Tooltip>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OnboardingCard;
|
||||||
|
|
@ -1,123 +1,15 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { Suspense } from "react";
|
||||||
import { Suspense, useEffect, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import {
|
|
||||||
type OnboardingVariables,
|
|
||||||
useOnboardingMutation,
|
|
||||||
} from "@/app/api/mutations/useOnboardingMutation";
|
|
||||||
import { DoclingHealthBanner, useDoclingHealth } from "@/components/docling-health-banner";
|
import { DoclingHealthBanner, useDoclingHealth } from "@/components/docling-health-banner";
|
||||||
import IBMLogo from "@/components/logo/ibm-logo";
|
|
||||||
import OllamaLogo from "@/components/logo/ollama-logo";
|
|
||||||
import OpenAILogo from "@/components/logo/openai-logo";
|
|
||||||
import { ProtectedRoute } from "@/components/protected-route";
|
import { ProtectedRoute } from "@/components/protected-route";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardFooter,
|
|
||||||
CardHeader,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { DotPattern } from "@/components/ui/dot-pattern";
|
import { DotPattern } from "@/components/ui/dot-pattern";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import {
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@/components/ui/tooltip";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useGetSettingsQuery } from "../api/queries/useGetSettingsQuery";
|
import OnboardingCard from "./components/onboarding-card";
|
||||||
import { IBMOnboarding } from "./components/ibm-onboarding";
|
|
||||||
import { OllamaOnboarding } from "./components/ollama-onboarding";
|
|
||||||
import { OpenAIOnboarding } from "./components/openai-onboarding";
|
|
||||||
|
|
||||||
function OnboardingPage() {
|
function OnboardingPage() {
|
||||||
const { data: settingsDb, isLoading: isSettingsLoading } =
|
|
||||||
useGetSettingsQuery();
|
|
||||||
const { isHealthy: isDoclingHealthy } = useDoclingHealth();
|
const { isHealthy: isDoclingHealthy } = useDoclingHealth();
|
||||||
|
|
||||||
const redirect = "/";
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Redirect if already authenticated or in no-auth mode
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isSettingsLoading && settingsDb && settingsDb.edited) {
|
|
||||||
router.push(redirect);
|
|
||||||
}
|
|
||||||
}, [isSettingsLoading, settingsDb, router]);
|
|
||||||
|
|
||||||
const [modelProvider, setModelProvider] = useState<string>("openai");
|
|
||||||
|
|
||||||
const [sampleDataset, setSampleDataset] = useState<boolean>(true);
|
|
||||||
|
|
||||||
const handleSetModelProvider = (provider: string) => {
|
|
||||||
setModelProvider(provider);
|
|
||||||
setSettings({
|
|
||||||
model_provider: provider,
|
|
||||||
embedding_model: "",
|
|
||||||
llm_model: "",
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const [settings, setSettings] = useState<OnboardingVariables>({
|
|
||||||
model_provider: modelProvider,
|
|
||||||
embedding_model: "",
|
|
||||||
llm_model: "",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Mutations
|
|
||||||
const onboardingMutation = useOnboardingMutation({
|
|
||||||
onSuccess: (data) => {
|
|
||||||
console.log("Onboarding completed successfully", data);
|
|
||||||
router.push(redirect);
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
toast.error("Failed to complete onboarding", {
|
|
||||||
description: error.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleComplete = () => {
|
|
||||||
if (
|
|
||||||
!settings.model_provider ||
|
|
||||||
!settings.llm_model ||
|
|
||||||
!settings.embedding_model
|
|
||||||
) {
|
|
||||||
toast.error("Please complete all required fields");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepare onboarding data
|
|
||||||
const onboardingData: OnboardingVariables = {
|
|
||||||
model_provider: settings.model_provider,
|
|
||||||
llm_model: settings.llm_model,
|
|
||||||
embedding_model: settings.embedding_model,
|
|
||||||
sample_data: sampleDataset,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Add API key if available
|
|
||||||
if (settings.api_key) {
|
|
||||||
onboardingData.api_key = settings.api_key;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add endpoint if available
|
|
||||||
if (settings.endpoint) {
|
|
||||||
onboardingData.endpoint = settings.endpoint;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add project_id if available
|
|
||||||
if (settings.project_id) {
|
|
||||||
onboardingData.project_id = settings.project_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
onboardingMutation.mutate(onboardingData);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isComplete = !!settings.llm_model && !!settings.embedding_model && isDoclingHealthy;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-background relative p-4">
|
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-background relative p-4">
|
||||||
<DotPattern
|
<DotPattern
|
||||||
|
|
@ -140,75 +32,7 @@ function OnboardingPage() {
|
||||||
Connect a model provider
|
Connect a model provider
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<Card className="w-full max-w-[600px]">
|
<OnboardingCard isDoclingHealthy={isDoclingHealthy} />
|
||||||
<Tabs
|
|
||||||
defaultValue={modelProvider}
|
|
||||||
onValueChange={handleSetModelProvider}
|
|
||||||
>
|
|
||||||
<CardHeader>
|
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="openai">
|
|
||||||
<OpenAILogo className="w-4 h-4" />
|
|
||||||
OpenAI
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="watsonx">
|
|
||||||
<IBMLogo className="w-4 h-4" />
|
|
||||||
IBM watsonx.ai
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="ollama">
|
|
||||||
<OllamaLogo className="w-4 h-4" />
|
|
||||||
Ollama
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<TabsContent value="openai">
|
|
||||||
<OpenAIOnboarding
|
|
||||||
setSettings={setSettings}
|
|
||||||
sampleDataset={sampleDataset}
|
|
||||||
setSampleDataset={setSampleDataset}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="watsonx">
|
|
||||||
<IBMOnboarding
|
|
||||||
setSettings={setSettings}
|
|
||||||
sampleDataset={sampleDataset}
|
|
||||||
setSampleDataset={setSampleDataset}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="ollama">
|
|
||||||
<OllamaOnboarding
|
|
||||||
setSettings={setSettings}
|
|
||||||
sampleDataset={sampleDataset}
|
|
||||||
setSampleDataset={setSampleDataset}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
</CardContent>
|
|
||||||
</Tabs>
|
|
||||||
<CardFooter className="flex justify-end">
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleComplete}
|
|
||||||
disabled={!isComplete}
|
|
||||||
loading={onboardingMutation.isPending}
|
|
||||||
>
|
|
||||||
<span className="select-none">Complete</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TooltipTrigger>
|
|
||||||
{!isComplete && (
|
|
||||||
<TooltipContent>
|
|
||||||
{!!settings.llm_model && !!settings.embedding_model && !isDoclingHealthy
|
|
||||||
? "docling-serve must be running to continue"
|
|
||||||
: "Please fill in all required fields"}
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue