break up chat and onboarding components

This commit is contained in:
Mike Fortman 2025-10-14 16:32:40 -05:00
parent 2454cc978e
commit f51890a90f
8 changed files with 927 additions and 753 deletions

View 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>
);
}

View 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 &quot;{filterSearchTerm}&quot;
</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";

View 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>
);
}

View 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>
);
}

View file

@ -1,98 +1,31 @@
"use client";
import {
Bot,
Check,
ChevronDown,
ChevronRight,
Funnel,
GitBranch,
Loader2,
Plus,
Settings,
User,
X,
Zap,
} from "lucide-react";
import { Bot, Loader2, Zap } from "lucide-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 { 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 { useKnowledgeFilter } from "@/contexts/knowledge-filter-context";
import { useTask } from "@/contexts/task-context";
import { useLoadingStore } from "@/stores/loadingStore";
import { useGetNudgesQuery } from "../api/queries/useGetNudgesQuery";
import Nudges from "./nudges";
interface Message {
role: "user" | "assistant";
content: string;
timestamp: Date;
functionCalls?: FunctionCall[];
isStreaming?: boolean;
}
interface FunctionCall {
name: string;
arguments?: Record<string, unknown>;
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;
}
import { UserMessage } from "./components/user-message";
import { AssistantMessage } from "./components/assistant-message";
import { ChatInput, type ChatInputHandle } from "./components/chat-input";
import { Button } from "@/components/ui/button";
import type {
Message,
FunctionCall,
ToolCallResult,
SelectedFilters,
KnowledgeFilterData,
RequestBody,
} from "./types";
function ChatPage() {
const isDebugMode =
process.env.NODE_ENV === "development" ||
process.env.NEXT_PUBLIC_OPENRAG_DEBUG === "true";
const { user } = useAuth();
const {
endpoint,
setEndpoint,
@ -143,8 +76,7 @@ function ChatPage() {
y: number;
} | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chatInputRef = useRef<ChatInputHandle>(null);
const streamAbortRef = useRef<AbortController | null>(null);
const streamIdRef = useRef(0);
const lastLoadedConversationRef = useRef<string | null>(null);
@ -337,7 +269,7 @@ function ChatPage() {
};
const handleFilePickerClick = () => {
fileInputRef.current?.click();
chatInputRef.current?.clickFileInput();
};
const handleFilePickerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -345,10 +277,6 @@ function ChatPage() {
if (files && files.length > 0) {
handleFileUpload(files[0]);
}
// Reset the input so the same file can be selected again
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
};
const loadAvailableFilters = async () => {
@ -412,7 +340,7 @@ function ChatPage() {
// Auto-focus the input on component mount
useEffect(() => {
inputRef.current?.focus();
chatInputRef.current?.focusInput();
}, []);
// Explicitly handle external new conversation trigger
@ -439,7 +367,7 @@ function ChatPage() {
};
const handleFocusInput = () => {
inputRef.current?.focus();
chatInputRef.current?.focusInput();
};
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
};
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) => {
handleSendMessage(suggestion);
@ -1915,7 +1613,7 @@ function ChatPage() {
setDropdownDismissed(true);
// Keep focus on the textarea so user can continue typing normally
inputRef.current?.focus();
chatInputRef.current?.focusInput();
return;
}
@ -2121,74 +1819,33 @@ function ChatPage() {
{messages.map((message, index) => (
<div key={index} className="space-y-6 group">
{message.role === "user" && (
<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">
{message.content}
</p>
</div>
</div>
<UserMessage content={message.content} />
)}
{message.role === "assistant" && (
<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">
{renderFunctionCalls(
message.functionCalls || [],
index
)}
<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>
<AssistantMessage
content={message.content}
functionCalls={message.functionCalls}
messageIndex={index}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={toggleFunctionCall}
showForkButton={endpoint === "chat"}
onFork={e => handleForkConversation(index, e)}
/>
)}
</div>
))}
{/* Streaming Message Display */}
{streamingMessage && (
<div className="flex gap-3">
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
<Bot className="h-4 w-4 text-accent-foreground" />
</div>
<div className="flex-1">
{renderFunctionCalls(
streamingMessage.functionCalls,
messages.length
)}
<MarkdownRenderer
chatMessage={streamingMessage.content}
/>
<span className="inline-block w-2 h-4 bg-blue-400 ml-1 animate-pulse"></span>
</div>
</div>
<AssistantMessage
content={streamingMessage.content}
functionCalls={streamingMessage.functionCalls}
messageIndex={messages.length}
expandedFunctionCalls={expandedFunctionCalls}
onToggle={toggleFunctionCall}
isStreaming
/>
)}
{/* Loading animation - shows immediately after user submits */}
@ -2223,201 +1880,32 @@ function ChatPage() {
)}
{/* Input Area - Fixed at bottom */}
<div className="pb-8 pt-4 flex px-6">
<div className="w-full">
<form onSubmit={handleSubmit} 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={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 &quot;{filterSearchTerm}&quot;
</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>
<ChatInput
ref={chatInputRef}
input={input}
loading={loading}
isUploading={isUploading}
selectedFilter={selectedFilter}
isFilterHighlighted={isFilterHighlighted}
isFilterDropdownOpen={isFilterDropdownOpen}
availableFilters={availableFilters}
filterSearchTerm={filterSearchTerm}
selectedFilterIndex={selectedFilterIndex}
anchorPosition={anchorPosition}
textareaHeight={textareaHeight}
parsedFilterData={parsedFilterData}
onSubmit={handleSubmit}
onChange={onChange}
onKeyDown={handleKeyDown}
onHeightChange={height => setTextareaHeight(height)}
onFilterSelect={handleFilterSelect}
onAtClick={onAtClick}
onFilePickerChange={handleFilePickerChange}
onFilePickerClick={handleFilePickerClick}
setSelectedFilter={setSelectedFilter}
setIsFilterHighlighted={setIsFilterHighlighted}
setIsFilterDropdownOpen={setIsFilterDropdownOpen}
/>
</div>
);
}

View 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;
}

View 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;

View file

@ -1,123 +1,15 @@
"use client";
import { useRouter } from "next/navigation";
import { Suspense, useEffect, useState } from "react";
import { toast } from "sonner";
import {
type OnboardingVariables,
useOnboardingMutation,
} from "@/app/api/mutations/useOnboardingMutation";
import { Suspense } from "react";
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 { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardFooter,
CardHeader,
} from "@/components/ui/card";
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 { useGetSettingsQuery } from "../api/queries/useGetSettingsQuery";
import { IBMOnboarding } from "./components/ibm-onboarding";
import { OllamaOnboarding } from "./components/ollama-onboarding";
import { OpenAIOnboarding } from "./components/openai-onboarding";
import OnboardingCard from "./components/onboarding-card";
function OnboardingPage() {
const { data: settingsDb, isLoading: isSettingsLoading } =
useGetSettingsQuery();
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 (
<div className="min-h-dvh w-full flex gap-5 flex-col items-center justify-center bg-background relative p-4">
<DotPattern
@ -140,75 +32,7 @@ function OnboardingPage() {
Connect a model provider
</h1>
</div>
<Card className="w-full max-w-[600px]">
<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>
<OnboardingCard isDoclingHealthy={isDoclingHealthy} />
</div>
</div>
);