Merge branch 'main' into embedding-tooltip

This commit is contained in:
Mike Fortman 2025-10-03 12:03:15 -05:00 committed by GitHub
commit 7232d8ea23
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 320 additions and 219 deletions

View file

@ -1,42 +1,60 @@
# Ingestion Configuration # Ingestion Configuration
# Set to true to disable Langflow ingestion and use traditional OpenRAG processor # Set to true to disable Langflow ingestion and use the traditional OpenRAG processor.
# If unset or false, Langflow pipeline will be used (default: upload -> ingest -> delete) # If unset or false, the Langflow pipeline is used (default: upload -> ingest -> delete).
DISABLE_INGEST_WITH_LANGFLOW=false DISABLE_INGEST_WITH_LANGFLOW=false
# make one like so https://docs.langflow.org/api-keys-and-authentication#langflow-secret-key
# Create a Langflow secret key:
# https://docs.langflow.org/api-keys-and-authentication#langflow-secret-key
LANGFLOW_SECRET_KEY= LANGFLOW_SECRET_KEY=
# flow ids for chat and ingestion flows
# Flow IDs for chat and ingestion
LANGFLOW_CHAT_FLOW_ID=1098eea1-6649-4e1d-aed1-b77249fb8dd0 LANGFLOW_CHAT_FLOW_ID=1098eea1-6649-4e1d-aed1-b77249fb8dd0
LANGFLOW_INGEST_FLOW_ID=5488df7c-b93f-4f87-a446-b67028bc0813 LANGFLOW_INGEST_FLOW_ID=5488df7c-b93f-4f87-a446-b67028bc0813
# Ingest flow using docling # Ingest flow using Docling
# LANGFLOW_INGEST_FLOW_ID=1402618b-e6d1-4ff2-9a11-d6ce71186915 # LANGFLOW_INGEST_FLOW_ID=1402618b-e6d1-4ff2-9a11-d6ce71186915
NUDGES_FLOW_ID=ebc01d31-1976-46ce-a385-b0240327226c NUDGES_FLOW_ID=ebc01d31-1976-46ce-a385-b0240327226c
# Set a strong admin password for OpenSearch; a bcrypt hash is generated at
# container startup from this value. Do not commit real secrets. # OpenSearch Auth
# must match the hashed password in secureconfig, must change for secure deployment!!! # Set a strong admin password for OpenSearch.
# A bcrypt hash is generated at container startup from this value.
# Do not commit real secrets.
# Must be changed for secure deployments.
OPENSEARCH_PASSWORD= OPENSEARCH_PASSWORD=
# make here https://console.cloud.google.com/apis/credentials
# Google OAuth
# Create credentials here:
# https://console.cloud.google.com/apis/credentials
GOOGLE_OAUTH_CLIENT_ID= GOOGLE_OAUTH_CLIENT_ID=
GOOGLE_OAUTH_CLIENT_SECRET= GOOGLE_OAUTH_CLIENT_SECRET=
# Azure app registration credentials for SharePoint/OneDrive
# Microsoft (SharePoint/OneDrive) OAuth
# Azure app registration credentials.
MICROSOFT_GRAPH_OAUTH_CLIENT_ID= MICROSOFT_GRAPH_OAUTH_CLIENT_ID=
MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET= MICROSOFT_GRAPH_OAUTH_CLIENT_SECRET=
# OPTIONAL: dns routable from google (etc.) to handle continous ingest (something like ngrok works). This enables continous ingestion
# Webhooks (optional)
# Public, DNS-resolvable base URL (e.g., via ngrok) for continuous ingestion.
WEBHOOK_BASE_URL= WEBHOOK_BASE_URL=
# API Keys
OPENAI_API_KEY= OPENAI_API_KEY=
AWS_ACCESS_KEY_ID= AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY= AWS_SECRET_ACCESS_KEY=
# OPTIONAL url for openrag link to langflow in the UI
# Langflow UI URL (optional)
# Public URL to link OpenRAG to Langflow in the UI.
LANGFLOW_PUBLIC_URL= LANGFLOW_PUBLIC_URL=
# Langflow auth
# Langflow Auth
LANGFLOW_AUTO_LOGIN=False LANGFLOW_AUTO_LOGIN=False
LANGFLOW_SUPERUSER= LANGFLOW_SUPERUSER=
LANGFLOW_SUPERUSER_PASSWORD= LANGFLOW_SUPERUSER_PASSWORD=

View file

@ -1,8 +1,7 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Button } from "@/components/ui/button"; import { Plus } from "lucide-react";
import { Loader2, Plus } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { import {
useGetFiltersSearchQuery, useGetFiltersSearchQuery,
@ -65,97 +64,102 @@ export function KnowledgeFilterList({
}; };
return ( return (
<> <div className="flex-1 min-h-0 flex flex-col">
<div className="flex flex-col gap-2 px-3 !mb-12 mt-0 h-full overflow-y-auto"> <div className="px-3 flex-1 min-h-0 flex flex-col">
<div className="flex items-center w-full justify-between pl-3"> <div className="flex-shrink-0">
<div className="text-sm font-medium text-muted-foreground"> <div className="flex items-center justify-between mb-3 ml-3 mr-2">
Knowledge Filters <h3 className="text-xs font-medium text-muted-foreground">
</div> Knowledge Filters
<Button </h3>
variant="ghost" <button
size="sm" type="button"
onClick={handleCreateNew} className="p-1 hover:bg-accent rounded"
title="Create New Filter" onClick={handleCreateNew}
className="!h-8 w-8 px-0 text-muted-foreground" title="Create New Filter"
>
<Plus className="h-3 w-3" />
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="ml-2 text-sm text-muted-foreground">
Loading...
</span>
</div>
) : filters.length === 0 ? (
<div className="py-2 px-4 text-sm text-muted-foreground">
{searchQuery ? "No filters found" : "No saved filters"}
</div>
) : (
filters.map((filter) => (
<div
key={filter.id}
onClick={() => handleFilterSelect(filter)}
className={cn(
"flex items-center gap-3 px-3 py-2 w-full rounded-lg hover:bg-accent hover:text-accent-foreground cursor-pointer group transition-colors",
selectedFilter?.id === filter.id &&
"active bg-accent text-accent-foreground"
)}
> >
<div className="flex flex-col gap-1 flex-1 min-w-0"> <Plus className="h-4 w-4 text-muted-foreground" />
<div className="flex items-center gap-2"> </button>
{(() => { </div>
const parsed = parseQueryData( <div className="overflow-y-auto scrollbar-hide space-y-1">
filter.query_data {loading ? (
) as ParsedQueryData; <div className="text-[13px] text-muted-foreground p-2 ml-1">
const Icon = iconKeyToComponent(parsed.icon); Loading...
return (
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded flex-shrink-0 transition-colors",
filterAccentClasses[parsed.color],
parsed.color === "zinc" &&
"group-hover:bg-background group-[.active]:bg-background"
)}
>
{Icon && <Icon className="h-3 w-3" />}
</div>
);
})()}
<div className="text-sm font-medium truncate group-hover:text-accent-foreground">
{filter.name}
</div>
</div>
{filter.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{filter.description}
</div>
)}
<div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground">
{new Date(filter.created_at).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</div>
<span className="text-xs bg-muted text-muted-foreground px-1 py-0.5 rounded-sm group-hover:bg-background group-[.active]:bg-background transition-colors">
{(() => {
const dataSources = parseQueryData(filter.query_data)
.filters.data_sources;
if (dataSources[0] === "*") return "All sources";
const count = dataSources.length;
return `${count} ${count === 1 ? "source" : "sources"}`;
})()}
</span>
</div>
</div> </div>
</div> ) : filters.length === 0 ? (
)) <div className="text-[13px] text-muted-foreground p-2 ml-1">
)} {searchQuery ? "No filters found" : "No saved filters"}
</div>
) : (
filters.map(filter => (
<div
key={filter.id}
onClick={() => handleFilterSelect(filter)}
className={cn(
"flex items-center gap-3 px-3 py-2 w-full rounded-lg hover:bg-accent hover:text-accent-foreground cursor-pointer group transition-colors",
selectedFilter?.id === filter.id &&
"active bg-accent text-accent-foreground"
)}
>
<div className="flex flex-col gap-1 flex-1 min-w-0">
<div className="flex items-center gap-2">
{(() => {
const parsed = parseQueryData(
filter.query_data
) as ParsedQueryData;
const Icon = iconKeyToComponent(parsed.icon);
return (
<div
className={cn(
"flex items-center justify-center w-5 h-5 rounded transition-colors",
filterAccentClasses[parsed.color],
parsed.color === "zinc" &&
"group-hover:bg-background group-[.active]:bg-background"
)}
>
{Icon && <Icon className="h-3 w-3" />}
</div>
);
})()}
<div className="text-sm font-medium truncate group-hover:text-accent-foreground">
{filter.name}
</div>
</div>
{filter.description && (
<div className="text-xs text-muted-foreground line-clamp-2">
{filter.description}
</div>
)}
<div className="flex items-center gap-2">
<div className="text-xs text-muted-foreground">
{new Date(filter.created_at).toLocaleDateString(
undefined,
{
month: "short",
day: "numeric",
year: "numeric",
}
)}
</div>
<span className="text-xs bg-muted text-muted-foreground px-1 py-0.5 rounded-sm group-hover:bg-background group-[.active]:bg-background transition-colors">
{(() => {
const dataSources = parseQueryData(filter.query_data)
.filters.data_sources;
if (dataSources[0] === "*") return "All sources";
const count = dataSources.length;
return `${count} ${
count === 1 ? "source" : "sources"
}`;
})()}
</span>
</div>
</div>
</div>
))
)}
</div>
</div>
{/* Create flow moved to panel create mode */}
</div> </div>
{/* Create flow moved to panel create mode */} </div>
</>
); );
} }

View file

@ -1,7 +1,10 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useGetConversationsQuery, type ChatConversation } from "@/app/api/queries/useGetConversationsQuery"; import {
useGetConversationsQuery,
type ChatConversation,
} from "@/app/api/queries/useGetConversationsQuery";
import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown"; import { KnowledgeFilterDropdown } from "@/components/knowledge-filter-dropdown";
import { ModeToggle } from "@/components/mode-toggle"; import { ModeToggle } from "@/components/mode-toggle";
import { Navigation } from "@/components/navigation"; import { Navigation } from "@/components/navigation";

View file

@ -5,7 +5,6 @@ import {
FileText, FileText,
Library, Library,
MessageSquare, MessageSquare,
MoreHorizontal,
Plus, Plus,
Settings2, Settings2,
Trash2, Trash2,
@ -111,7 +110,7 @@ export function Navigation({
) { ) {
// Filter out the deleted conversation and find the next one // Filter out the deleted conversation and find the next one
const remainingConversations = conversations.filter( const remainingConversations = conversations.filter(
(conv) => conv.response_id !== conversationToDelete.response_id, conv => conv.response_id !== conversationToDelete.response_id
); );
if (remainingConversations.length > 0) { if (remainingConversations.length > 0) {
@ -132,7 +131,7 @@ export function Navigation({
setDeleteModalOpen(false); setDeleteModalOpen(false);
setConversationToDelete(null); setConversationToDelete(null);
}, },
onError: (error) => { onError: error => {
toast.error(`Failed to delete conversation: ${error.message}`); toast.error(`Failed to delete conversation: ${error.message}`);
}, },
}); });
@ -164,7 +163,7 @@ export function Navigation({
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("fileUploadStart", { new CustomEvent("fileUploadStart", {
detail: { filename: file.name }, detail: { filename: file.name },
}), })
); );
try { try {
@ -188,7 +187,7 @@ export function Navigation({
filename: file.name, filename: file.name,
error: "Failed to process document", error: "Failed to process document",
}, },
}), })
); );
// Trigger loading end event // Trigger loading end event
@ -208,7 +207,7 @@ export function Navigation({
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("fileUploaded", { new CustomEvent("fileUploaded", {
detail: { file, result }, detail: { file, result },
}), })
); );
// Trigger loading end event // Trigger loading end event
@ -222,7 +221,7 @@ export function Navigation({
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("fileUploadError", { new CustomEvent("fileUploadError", {
detail: { filename: file.name, error: "Failed to process document" }, detail: { filename: file.name, error: "Failed to process document" },
}), })
); );
} }
}; };
@ -244,7 +243,7 @@ export function Navigation({
const handleDeleteConversation = ( const handleDeleteConversation = (
conversation: ChatConversation, conversation: ChatConversation,
event?: React.MouseEvent, event?: React.MouseEvent
) => { ) => {
if (event) { if (event) {
event.preventDefault(); event.preventDefault();
@ -256,7 +255,7 @@ export function Navigation({
const handleContextMenuAction = ( const handleContextMenuAction = (
action: string, action: string,
conversation: ChatConversation, conversation: ChatConversation
) => { ) => {
switch (action) { switch (action) {
case "delete": case "delete":
@ -332,33 +331,33 @@ export function Navigation({
return ( return (
<div className="flex flex-col h-full bg-background"> <div className="flex flex-col h-full bg-background">
<div className="px-3 py-2 flex-shrink-0"> <div className="px-4 py-2 flex-shrink-0">
<div className="space-y-1"> <div className="space-y-1">
{routes.map((route) => ( {routes.map(route => (
<div key={route.href}> <div key={route.href}>
<Link <Link
href={route.href} href={route.href}
className={cn( className={cn(
"text-sm group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all", "text-[13px] group flex p-3 w-full justify-start font-medium cursor-pointer hover:bg-accent hover:text-accent-foreground rounded-lg transition-all",
route.active route.active
? "bg-accent text-accent-foreground shadow-sm" ? "bg-accent text-accent-foreground shadow-sm"
: "text-foreground hover:text-accent-foreground", : "text-foreground hover:text-accent-foreground"
)} )}
> >
<div className="flex items-center flex-1"> <div className="flex items-center flex-1">
<route.icon <route.icon
className={cn( className={cn(
"h-4 w-4 mr-3 shrink-0", "h-[18px] w-[18px] mr-2 shrink-0",
route.active route.active
? "text-accent-foreground" ? "text-muted-foreground"
: "text-muted-foreground group-hover:text-foreground", : "text-muted-foreground group-hover:text-muted-foreground"
)} )}
/> />
{route.label} {route.label}
</div> </div>
</Link> </Link>
{route.label === "Settings" && ( {route.label === "Settings" && (
<div className="mx-3 my-2 border-t border-border/40" /> <div className="my-2 border-t border-border" />
)} )}
</div> </div>
))} ))}
@ -374,11 +373,11 @@ export function Navigation({
{/* Chat Page Specific Sections */} {/* Chat Page Specific Sections */}
{isOnChatPage && ( {isOnChatPage && (
<div className="flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col px-4">
{/* Conversations Section */} {/* Conversations Section */}
<div className="px-3 flex-shrink-0"> <div className="flex-shrink-0">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3 mx-3">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-xs font-medium text-muted-foreground">
Conversations Conversations
</h3> </h3>
<button <button
@ -393,11 +392,11 @@ export function Navigation({
</div> </div>
</div> </div>
<div className="px-3 flex-1 min-h-0 flex flex-col"> <div className="flex-1 min-h-0 flex flex-col">
{/* Conversations List - grows naturally, doesn't fill all space */} {/* Conversations List - grows naturally, doesn't fill all space */}
<div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full"> <div className="flex-shrink-0 overflow-y-auto scrollbar-hide space-y-1 max-h-full">
{loadingNewConversation || isConversationsLoading ? ( {loadingNewConversation || isConversationsLoading ? (
<div className="text-sm text-muted-foreground p-2"> <div className="text-[13px] text-muted-foreground p-2">
Loading... Loading...
</div> </div>
) : ( ) : (
@ -406,7 +405,7 @@ export function Navigation({
{placeholderConversation && ( {placeholderConversation && (
<button <button
type="button" type="button"
className="w-full p-2 rounded-lg bg-accent/50 border border-dashed border-accent cursor-pointer group text-left" className="w-full px-3 rounded-lg bg-accent border border-dashed border-accent cursor-pointer group text-left h-[44px]"
onClick={() => { onClick={() => {
// Don't load placeholder as a real conversation, just focus the input // Don't load placeholder as a real conversation, just focus the input
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -414,7 +413,7 @@ export function Navigation({
} }
}} }}
> >
<div className="text-sm font-medium text-foreground mb-1 truncate"> <div className="text-[13px] font-medium text-foreground truncate">
{placeholderConversation.title} {placeholderConversation.title}
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
@ -425,15 +424,15 @@ export function Navigation({
{/* Show regular conversations */} {/* Show regular conversations */}
{conversations.length === 0 && !placeholderConversation ? ( {conversations.length === 0 && !placeholderConversation ? (
<div className="text-sm text-muted-foreground p-2"> <div className="text-[13px] text-muted-foreground py-2 pl-3">
No conversations yet No conversations yet
</div> </div>
) : ( ) : (
conversations.map((conversation) => ( conversations.map(conversation => (
<button <button
key={conversation.response_id} key={conversation.response_id}
type="button" type="button"
className={`w-full px-3 pr-2 h-11 rounded-lg group relative text-left ${ className={`w-full px-3 h-11 rounded-lg group relative text-left ${
loading loading
? "opacity-50 cursor-not-allowed" ? "opacity-50 cursor-not-allowed"
: "hover:bg-accent cursor-pointer" : "hover:bg-accent cursor-pointer"
@ -456,17 +455,22 @@ export function Navigation({
</div> </div>
</div> </div>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger disabled={loading || deleteSessionMutation.isPending} asChild> <DropdownMenuTrigger
disabled={
loading || deleteSessionMutation.isPending
}
asChild
>
<div <div
className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground ml-2 flex-shrink-0 cursor-pointer" className="opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 data-[state=open]:text-foreground transition-opacity p-1 hover:bg-accent rounded text-muted-foreground hover:text-foreground ml-2 flex-shrink-0 cursor-pointer"
title="More options" title="More options"
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
}} }}
onKeyDown={(e) => { onKeyDown={e => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === "Enter" || e.key === " ") {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
} }
@ -479,14 +483,14 @@ export function Navigation({
side="bottom" side="bottom"
align="end" align="end"
className="w-48" className="w-48"
onClick={(e) => e.stopPropagation()} onClick={e => e.stopPropagation()}
> >
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={e => {
e.stopPropagation(); e.stopPropagation();
handleContextMenuAction( handleContextMenuAction(
"delete", "delete",
conversation, conversation
); );
}} }}
className="cursor-pointer text-destructive focus:text-destructive" className="cursor-pointer text-destructive focus:text-destructive"
@ -506,8 +510,8 @@ export function Navigation({
{/* Conversation Knowledge Section - appears right after last conversation */} {/* Conversation Knowledge Section - appears right after last conversation */}
<div className="flex-shrink-0 mt-4"> <div className="flex-shrink-0 mt-4">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3 mx-3">
<h3 className="text-sm font-medium text-muted-foreground"> <h3 className="text-xs font-medium text-muted-foreground">
Conversation knowledge Conversation knowledge
</h3> </h3>
<button <button
@ -526,16 +530,16 @@ export function Navigation({
className="hidden" className="hidden"
accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt" accept=".pdf,.doc,.docx,.txt,.md,.rtf,.odt"
/> />
<div className="overflow-y-auto scrollbar-hide space-y-1 max-h-40"> <div className="overflow-y-auto scrollbar-hide space-y-1">
{conversationDocs.length === 0 ? ( {conversationDocs.length === 0 ? (
<div className="text-sm text-muted-foreground p-2"> <div className="text-[13px] text-muted-foreground py-2 px-3">
No documents yet No documents yet
</div> </div>
) : ( ) : (
conversationDocs.map((doc) => ( conversationDocs.map(doc => (
<div <div
key={`${doc.filename}-${doc.uploadTime.getTime()}`} key={`${doc.filename}-${doc.uploadTime.getTime()}`}
className="p-2 rounded-lg hover:bg-accent cursor-pointer group flex items-center" className="w-full px-3 h-11 rounded-lg hover:bg-accent cursor-pointer group flex items-center"
> >
<FileText className="h-4 w-4 mr-2 text-muted-foreground flex-shrink-0" /> <FileText className="h-4 w-4 mr-2 text-muted-foreground flex-shrink-0" />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">

View file

@ -58,7 +58,7 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
// Calculate active tasks for the bell icon // Calculate active tasks for the bell icon
const activeTasks = tasks.filter( const activeTasks = tasks.filter(
(task) => task =>
task.status === "pending" || task.status === "pending" ||
task.status === "running" || task.status === "running" ||
task.status === "processing" task.status === "processing"
@ -84,16 +84,16 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
// For all other pages, render with Langflow-styled navigation and task menu // For all other pages, render with Langflow-styled navigation and task menu
return ( return (
<div className="h-full relative"> <div className="h-full relative">
<header className="header-arrangement bg-background sticky top-0 z-50"> <header className="header-arrangement bg-background sticky top-0 z-50 h-10">
<div className="header-start-display px-4"> <div className="header-start-display px-[16px]">
{/* Logo/Title */} {/* Logo/Title */}
<div className="flex items-center gap-2"> <div className="flex items-center">
<Logo className="fill-primary" width={24} height={22} /> <Logo className="fill-primary" width={24} height={22} />
<span className="text-lg font-semibold">OpenRAG</span> <span className="text-lg font-semibold pl-2.5">OpenRAG</span>
</div> </div>
</div> </div>
<div className="header-end-division"> <div className="header-end-division">
<div className="header-end-display"> <div className="justify-end flex items-center">
{/* Knowledge Filter Dropdown */} {/* Knowledge Filter Dropdown */}
{/* <KnowledgeFilterDropdown {/* <KnowledgeFilterDropdown
selectedFilter={selectedFilter} selectedFilter={selectedFilter}
@ -107,26 +107,24 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) {
{/* <DiscordLink inviteCode="EqksyE2EX9" /> */} {/* <DiscordLink inviteCode="EqksyE2EX9" /> */}
{/* Task Notification Bell */} {/* Task Notification Bell */}
<Button <button
variant="ghost"
size="iconSm"
onClick={toggleMenu} onClick={toggleMenu}
className="relative" className="h-8 w-8 hover:bg-muted rounded-lg flex items-center justify-center"
> >
<Bell className="h-4 w-4 text-muted-foreground" /> <Bell size={16} className="text-muted-foreground" />
{activeTasks.length > 0 && ( {activeTasks.length > 0 && (
<div className="header-notifications" /> <div className="header-notifications" />
)} )}
</Button> </button>
{/* Separator */} {/* Separator */}
<div className="w-px h-6 bg-border" /> <div className="w-px h-6 bg-border mx-3" />
<UserNav /> <UserNav />
</div> </div>
</div> </div>
</header> </header>
<div className="side-bar-arrangement bg-background fixed left-0 top-[53px] bottom-0 md:flex hidden"> <div className="side-bar-arrangement bg-background fixed left-0 top-[40px] bottom-0 md:flex hidden pt-1">
<Navigation <Navigation
conversations={conversations} conversations={conversations}
isConversationsLoading={isConversationsLoading} isConversationsLoading={isConversationsLoading}

View file

@ -0,0 +1,68 @@
import { useEffect, useState } from "react";
import { Monitor, Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
export const ThemeButtons = () => {
const { theme, setTheme } = useTheme();
const [selectedTheme, setSelectedTheme] = useState("dark");
// Sync local state with theme context
useEffect(() => {
if (theme) {
setSelectedTheme(theme);
}
}, [theme]);
const handleThemeChange = (newTheme: string) => {
setSelectedTheme(newTheme);
setTheme(newTheme);
};
return (
<div className="flex items-center border border-border rounded-full">
{/* Light Theme Button */}
<button
className={`h-6 w-6 rounded-full flex items-center justify-center ${
selectedTheme === "light"
? "bg-amber-400 text-primary"
: "text-foreground hover:bg-amber-400 hover:text-background"
}`}
onClick={() => handleThemeChange("light")}
data-testid="menu_light_button"
id="menu_light_button"
>
<Sun className="h-4 w-4 rounded-full" />
</button>
{/* Dark Theme Button */}
<button
className={`h-6 w-6 rounded-full flex items-center justify-center ${
selectedTheme === "dark"
? "bg-purple-500/20 text-purple-500 hover:bg-purple-500/20 hover:text-purple-500"
: "text-foreground hover:bg-purple-500/20 hover:text-purple-500"
}`}
onClick={() => handleThemeChange("dark")}
data-testid="menu_dark_button"
id="menu_dark_button"
>
<Moon className="h-4 w-4" />
</button>
{/* System Theme Button */}
<button
className={`h-6 w-6 rounded-full flex items-center justify-center ${
selectedTheme === "system"
? "bg-foreground text-background"
: "hover:bg-foreground hover:text-background"
}`}
onClick={() => handleThemeChange("system")}
data-testid="menu_system_button"
id="menu_system_button"
>
<Monitor className="h-4 w-4" />
</button>
</div>
);
};
export default ThemeButtons;

View file

@ -1,94 +1,100 @@
"use client" "use client";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { useAuth } from "@/contexts/auth-context" import { useAuth } from "@/contexts/auth-context";
import { LogIn, LogOut, User, Moon, Sun, ChevronsUpDown } from "lucide-react" import { LogOut, User, Moon, Sun, ChevronsUpDown } from "lucide-react";
import { useTheme } from "next-themes" import { useTheme } from "next-themes";
import ThemeButtons from "./ui/buttonTheme";
export function UserNav() { export function UserNav() {
const { user, isLoading, isAuthenticated, isNoAuthMode, login, logout } = useAuth() const { user, isLoading, isAuthenticated, isNoAuthMode, login, logout } =
const { theme, setTheme } = useTheme() useAuth();
const { theme, setTheme } = useTheme();
if (isLoading) { if (isLoading) {
return ( return <div className="h-8 w-8 rounded-full bg-muted animate-pulse" />;
<div className="h-8 w-8 rounded-full bg-muted animate-pulse" />
)
} }
// In no-auth mode, show a simple theme switcher instead of auth UI // In no-auth mode, show a simple theme switcher instead of auth UI
if (isNoAuthMode) { if (isNoAuthMode) {
return ( return (
<Button <button
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')} onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
variant="outline" className="flex justify-center items-center gap-2 h-8 w-8 mr-2 rounded-md hover:bg-muted rounded-lg "
size="sm"
className="flex items-center gap-2"
> >
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />} {theme === "dark" ? (
</Button> <Sun size={16} className="text-muted-foreground" />
) ) : (
<Moon size={16} className="text-muted-foreground" />
)}
</button>
);
} }
if (!isAuthenticated) { if (!isAuthenticated) {
return ( return (
<Button <button
onClick={login} onClick={login}
variant="outline" className="flex items-center gap-2 h-7 px-3 mr-2 rounded-md bg-primary hover:bg-primary/90 text-primary-foreground text-[13px] line-height-[16px]"
size="sm"
className="flex items-center gap-2"
> >
<LogIn className="h-4 w-4" />
Sign In Sign In
</Button> </button>
) );
} }
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="flex items-center gap-1 h-8 px-1 rounded-full"> <button className="hover:bg-accent rounded-lg pl-[4px] p-[3px] flex items-center justify-center">
<Avatar className="h-6 w-6"> <Avatar className="rounded-md w-7 h-7">
<AvatarImage src={user?.picture} alt={user?.name} /> <AvatarImage
<AvatarFallback className="text-xs"> width={16}
{user?.name ? user.name.charAt(0).toUpperCase() : <User className="h-3 w-3" />} height={16}
src={user?.picture}
alt={user?.name}
className="rounded-md"
/>
<AvatarFallback className="text-xs rounded-md">
{user?.name ? (
user.name.charAt(0).toUpperCase()
) : (
<User className="h-3 w-3" />
)}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<ChevronsUpDown className="h-3 w-3 text-muted-foreground" /> <ChevronsUpDown size={16} className="text-muted-foreground mx-2" />
</Button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="w-56 p-0" align="end" forceMount>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-2 px-1 py-1">
<p className="text-sm font-medium leading-none">{user?.name}</p> <p className="text-sm font-medium leading-none">{user?.name}</p>
<p className="text-xs leading-none text-muted-foreground"> <p className="text-xs leading-none text-muted-foreground">
{user?.email} {user?.email}
</p> </p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator className="m-0" />
<DropdownMenuItem onClick={() => setTheme(theme === "light" ? "dark" : "light")}> <div className="flex items-center justify-between pl-3 pr-2 h-9">
{theme === "light" ? ( <span className="text-sm">Theme</span>
<Moon className="mr-2 h-4 w-4" /> <ThemeButtons />
) : ( </div>
<Sun className="mr-2 h-4 w-4" /> <DropdownMenuSeparator className="m-0" />
)} <button
<span>Toggle Theme</span> onClick={logout}
</DropdownMenuItem> className="flex items-center hover:bg-muted w-full h-9 px-3"
<DropdownMenuSeparator /> >
<DropdownMenuItem onClick={logout} className="text-red-600 focus:text-red-600"> <LogOut className="mr-2 h-4 w-4 text-muted-foreground" />
<LogOut className="mr-2 h-4 w-4" /> <span className="text-sm">Logout</span>
<span>Log out</span> </button>
</DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) );
} }

View file

@ -96,7 +96,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
const refreshConversations = useCallback((force = false) => { const refreshConversations = useCallback((force = false) => {
if (force) { if (force) {
// Immediate refresh for important updates like new conversations // Immediate refresh for important updates like new conversations
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger(prev => prev + 1);
return; return;
} }
@ -107,7 +107,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
// Set a new timeout to debounce multiple rapid refresh calls // Set a new timeout to debounce multiple rapid refresh calls
refreshTimeoutRef.current = setTimeout(() => { refreshTimeoutRef.current = setTimeout(() => {
setRefreshTrigger((prev) => prev + 1); setRefreshTrigger(prev => prev + 1);
}, 250); // 250ms debounce }, 250); // 250ms debounce
}, []); }, []);
@ -123,7 +123,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
// Silent refresh - updates data without loading states // Silent refresh - updates data without loading states
const refreshConversationsSilent = useCallback(async () => { const refreshConversationsSilent = useCallback(async () => {
// Trigger silent refresh that updates conversation data without showing loading states // Trigger silent refresh that updates conversation data without showing loading states
setRefreshTriggerSilent((prev) => prev + 1); setRefreshTriggerSilent(prev => prev + 1);
}, []); }, []);
const loadConversation = useCallback((conversation: ConversationData) => { const loadConversation = useCallback((conversation: ConversationData) => {
@ -164,7 +164,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
}, [endpoint, refreshConversations]); }, [endpoint, refreshConversations]);
const addConversationDoc = useCallback((filename: string) => { const addConversationDoc = useCallback((filename: string) => {
setConversationDocs((prev) => [ setConversationDocs(prev => [
...prev, ...prev,
{ filename, uploadTime: new Date() }, { filename, uploadTime: new Date() },
]); ]);
@ -180,7 +180,7 @@ export function ChatProvider({ children }: ChatProviderProps) {
setCurrentConversationId(null); // Clear current conversation to indicate new conversation setCurrentConversationId(null); // Clear current conversation to indicate new conversation
setConversationData(null); // Clear conversation data to prevent reloading setConversationData(null); // Clear conversation data to prevent reloading
// Set the response ID that we're forking from as the previous response ID // Set the response ID that we're forking from as the previous response ID
setPreviousResponseIds((prev) => ({ setPreviousResponseIds(prev => ({
...prev, ...prev,
[endpoint]: responseId, [endpoint]: responseId,
})); }));