diff --git a/.env.example b/.env.example index 8cfd575..5ee29ec 100644 --- a/.env.example +++ b/.env.example @@ -1 +1 @@ -NEXT_PUBLIC_OLLAMA_BASEURL= +NEXT_PUBLIC_OLLAMA_BASEURL= \ No newline at end of file diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..13566b8 --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,8 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..c7ddf09 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/.idea/minimal-llm-ui.iml b/.idea/minimal-llm-ui.iml new file mode 100644 index 0000000..24643cc --- /dev/null +++ b/.idea/minimal-llm-ui.iml @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..29175b5 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml new file mode 100644 index 0000000..b0c1c68 --- /dev/null +++ b/.idea/prettier.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/APIs.md b/APIs.md new file mode 100644 index 0000000..cfe824a --- /dev/null +++ b/APIs.md @@ -0,0 +1,89 @@ +## API's to the chat interface. + +API Host: http://localhost:5329 + +### Generic Database Search + +POST {{host}}/v1.0/sql/query +Content-Type: application/json +Accept: application/json + +{ +"query": "select content from \"view-9468964f-058a-4f2e-89ab-8b351ff082ba\" where document_guid = 'f49f92f7-8cdf-4f44-b327-0519e2aad881' and content LENGTH(content) > 5;", +"limit": 10 +} + +### Vector search + +#### Request +* Headers +```text +POST {{host}}/v1.0/vector/search +Content-Type: application/json +Accept: application/json +``` + +* BODY + +```json +{ + "query": "What is the main topic?", + "document_guid": "f49f92f7-8cdf-4f44-b327-0519e2aad881", + "limit": 5 +} +``` + +#### Response +* Headers + +```text +date: Fri, 18 Jul 2025 15:52:31 GMT +server: uvicorn +content-length: 1266 +content-type: application/json +x-request-id: 95aa4000-9c28-4093-9265-db47a2dde78d +``` + +* Body + +```json +{ + "status": "success", + "query": "What is the main topic?", + "document_guid": "f49f92f7-8cdf-4f44-b327-0519e2aad881", + "limit": 5, + "result_count": 5, + "results": [ + { + "id": 1, + "content": "Reference guide", + "length": 15, + "distance": 0.6795133566276237 + }, + { + "id": 1565, + "content": "Enterprise", + "length": 10, + "distance": 0.7974459138448121 + }, + { + "id": 363, + "content": "1 . Serial number label pull tab 5 . NIC status LED 2 . Quick removal access panel 6 . UID button / LED 3 . Power On / Standby button and system power LED 7 . GPU cage ( 8 DW GPUs ) 4 . Health LED 8 . 1U drive cage ( up to 8 SFF or 8 EDSFF 1T drives ) Figure 1 . HPE ProLiant Compute DL380a Gen12 Server—Front system detail", + "length": 323, + "distance": 0.9242449513442739 + }, + { + "id": 1079, + "content": "Chat now ( sales )", + "length": 18, + "distance": 0.9243938094603021 + }, + { + "id": 375, + "content": "7 8 9 10 11 1 . Power supply for the system board 6 . Power supply for the system board 2 . Power supplies for GPU auxiliary power 7 . VGA 3 . Primary riser slots 1 - 3 8 . Two ( 2 ) USB 3 . 0 ports 4 . Secondary riser slots 4 - 6 9 . HPE iLO management port 5 . Power supplies for GPU auxiliary power 10 . OCP 3 . 0 slot 1 11 . OCP 3 . 0 slot 2 Figure 2 . HPE ProLiant Compute DL380a Gen12 Server—Rear system detail", + "length": 416, + "distance": 0.9250164240618253 + } + ] +} +``` \ No newline at end of file diff --git a/public/Hewlett_Packard_Enterprise_logo.svg b/public/Hewlett_Packard_Enterprise_logo.svg new file mode 100644 index 0000000..f96c231 --- /dev/null +++ b/public/Hewlett_Packard_Enterprise_logo.svg @@ -0,0 +1,88 @@ + + +Hewlett Packard Enterprise logo + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/app/context/ModalContext.tsx b/src/app/context/ModalContext.tsx index 4292de6..679d2e6 100644 --- a/src/app/context/ModalContext.tsx +++ b/src/app/context/ModalContext.tsx @@ -10,6 +10,8 @@ export const useModal = () => { export enum AppModal { SAVE_PROMPT = "savePrompt", + ADD_SYSTEM_INSTRUCTION = "addSystemInstruction", + EDIT_SYSTEM_INSTRUCTION = "editSystemInstruction", } export const ModalProvider = ({ children }: { children: React.ReactNode }) => { @@ -27,4 +29,3 @@ export const ModalProvider = ({ children }: { children: React.ReactNode }) => { {children} ); }; - diff --git a/src/app/context/SystemInstructionContext.tsx b/src/app/context/SystemInstructionContext.tsx new file mode 100644 index 0000000..46e1d7c --- /dev/null +++ b/src/app/context/SystemInstructionContext.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { createContext, useContext, useState, useEffect } from "react"; +import { + SystemInstruction, + getDefaultSystemInstruction, + getSystemInstructionById, + getAllSystemInstructions, + addSystemInstruction, + updateSystemInstruction +} from "@/utils/systemInstructions"; + +// Create context with default values +const SystemInstructionContext = createContext<{ + activeSystemInstruction: SystemInstruction; + setActiveSystemInstructionById: (id: string) => void; + allSystemInstructions: SystemInstruction[]; + addNewSystemInstruction: (name: string, content: string) => void; + updateExistingSystemInstruction: (id: string, name: string, content: string) => void; +}>({ + activeSystemInstruction: getDefaultSystemInstruction(), + setActiveSystemInstructionById: () => {}, + allSystemInstructions: getAllSystemInstructions(), + addNewSystemInstruction: () => {}, + updateExistingSystemInstruction: () => {}, +}); + +// Hook to use the system instruction context +export function useSystemInstruction() { + return useContext(SystemInstructionContext); +} + +// Provider component +export function SystemInstructionProvider({ children }: { children: React.ReactNode }) { + // Initialize with the default system instruction + const [activeSystemInstruction, setActiveSystemInstruction] = useState( + getDefaultSystemInstruction() + ); + + const [allSystemInstructions, setAllSystemInstructions] = useState( + getAllSystemInstructions() + ); + + // Load the active system instruction from localStorage on component mount + useEffect(() => { + const storedInstructionId = localStorage.getItem("activeSystemInstructionId"); + if (storedInstructionId) { + const instruction = getSystemInstructionById(storedInstructionId); + if (instruction) { + setActiveSystemInstruction(instruction); + } + } + }, []); + + // Function to set the active system instruction by ID + const setActiveSystemInstructionById = (id: string) => { + const instruction = getSystemInstructionById(id); + if (instruction) { + setActiveSystemInstruction(instruction); + localStorage.setItem("activeSystemInstructionId", id); + } + }; + + // Function to add a new system instruction + const addNewSystemInstruction = (name: string, content: string) => { + // Create a new instruction object + const newInstruction: SystemInstruction = { + id: `instruction-${Date.now()}`, + name, + content + }; + + // Add it to the system instructions + addSystemInstruction(newInstruction); + + // Update the local state with the latest instructions + setAllSystemInstructions(getAllSystemInstructions()); + + // Return the new instruction ID in case it's needed + return newInstruction.id; + }; + + // Function to update an existing system instruction + const updateExistingSystemInstruction = (id: string, name: string, content: string) => { + // Update the instruction + updateSystemInstruction(id, name, content); + + // Update the local state with the latest instructions + setAllSystemInstructions(getAllSystemInstructions()); + + // If the active instruction was updated, update it in the state + if (activeSystemInstruction.id === id) { + const updatedInstruction = getSystemInstructionById(id); + if (updatedInstruction) { + setActiveSystemInstruction(updatedInstruction); + } + } + }; + + // Context value + const value = { + activeSystemInstruction, + setActiveSystemInstructionById, + allSystemInstructions, + addNewSystemInstruction, + updateExistingSystemInstruction, + }; + + return ( + + {children} + + ); +} diff --git a/src/app/globals.css b/src/app/globals.css index 2c5906b..dd4768a 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -3,68 +3,522 @@ @tailwind utilities; :root { + /* HPE Brand Colors */ + --hpe-green: #01a982; + --hpe-green-dark: #00896a; + --hpe-green-light: #01c897; + --hpe-dark: #0e0e0e; + --hpe-dark-secondary: #1a1a1a; + --hpe-gray-dark: #2d2d2d; + --hpe-gray-medium: #767676; + --hpe-gray-light: #d1d1d1; + --hpe-gray-lighter: #e8e8e8; + --hpe-gray-lightest: #f7f7f7; + --hpe-white: #ffffff; + + /* Apply HPE colors to existing variables */ --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; + --background-start-rgb: 247, 247, 247; + /* Light gray background */ + --background-end-rgb: 247, 247, 247; } -@media (prefers-color-scheme: dark) { +@media (prefers-color-scheme: light) { :root { --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; + --background-start-rgb: 247, 247, 247; + --background-end-rgb: 247, 247, 247; } } body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); + color: var(--hpe-gray-dark); + background: var(--hpe-gray-light); +} + +/* Main chat container - add padding for white chat area */ +main { + background-color: var(--hpe-gray-light); +} + +/* Override black backgrounds with HPE white */ +.bg-black { + background-color: var(--hpe-white); +} + +/* Override dark gray backgrounds */ +.bg-\[\#0a0a0a\]\/80 { + background-color: var(--hpe-white); + border-color: var(--hpe-gray-light); +} + +/* Navigation bar styling */ +nav { + background-color: var(--hpe-white); + border-bottom: 1px solid var(--hpe-gray-lighter); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); +} + +.chat-header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + background-color: var(--hpe-white); + border-bottom: 1px solid var(--border); + padding: 10px 24px; +} + +.header-content { + display: flex; + align-items: center; + gap: 20px; +} + +.logo-container { + width: 140px; + display: flex; + align-items: center; +} + +.logo { + width: 100%; + height: auto; + object-fit: contain; +} + +.header-title { + font-size: 18px; + font-weight: 600; + color: var(--hpe-gray-dark); + letter-spacing: -0.01em; +} + +/* Sidebar styling */ +.border-white\/10 { + border-color: var(--hpe-gray-lighter); +} + +/* Sidebar background when open */ +motion\:div:has(button) { + background-color: var(--hpe-hpe-green); +} + +/* New Chat button */ +.bg-white\/80 { + background-color: var(--hpe-green); + color: var(--hpe-white); +} + +.bg-white\/80:hover { + background-color: var(--hpe-green-dark); +} + +.btn-hpe-green { + background-color: var(--hpe-green); +} + +.btn-hpe-green:hover { + background-color: var(--hpe-green-dark); +} + +/* Conversation items */ +.hover\:bg-white\/5:hover { + background-color: var(--hpe-gray-lightest); +} + +/* Message containers */ +.border-\[\#191919\] { + border-color: var(--hpe-gray-lighter); + background-color: var(--hpe-white); +} + +/* User messages */ +.ml-auto>.border-\[\#191919\] { + background-color: var(--hpe-white); + border-color: var(--hpe-gray-light); +} + +/* AI messages */ +.mr-auto>.border-\[\#191919\] { + background-color: var(--hpe-white); + border-color: var(--hpe-gray-lighter); } -:not(pre) > code { - background: rgba(255, 255, 255, 0.1); +/* Text colors */ +.text-white { + color: var(--hpe-white); +} + +.text-white\/50 { + color: var(--hpe-gray-medium); +} + +.text-white\/80 { + color: var(--hpe-gray-medium); +} + +.text-white\/90 { + color: var(--hpe-gray-dark); +} + +/* Input fields */ +input, +textarea { + background-color: var(--hpe-white); + border-color: var(--hpe-gray-light); + color: var(--hpe-gray-dark); +} + +input:focus, +textarea:focus { + border-color: var(--hpe-green); +} + +input::placeholder, +textarea::placeholder { + color: var(--hpe-gray-medium); +} + +/* Model selector button */ +.modal-backdrop { + position: fixed; + inset: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.modal-content { + background-color: white; + border-radius: 8px; + box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); + max-width: 600px; + width: 90%; + max-height: 80vh; + overflow-y: auto; + padding: 24px; +} + +.modal-header { + display: flex; + justify-content: between; + align-items: center; + margin-bottom: 20px; + padding-bottom: 16px; + border-bottom: 1px solid var(--hpe-gray-lighter); +} + +.modal-title { + font-size: 20px; + font-weight: 600; + color: var(--hpe-gray-dark); +} + +.modal-close { + padding: 8px; border-radius: 4px; - padding: 2px 4px; + cursor: pointer; +} + +/* HPE POC Badge */ +.preset-name-container { + background-color: var(--hpe-green); + padding: 4px 12px; + border-radius: 16px; +} + +.preset-name { + color: white; + font-size: 12px; + font-weight: 500; +} + +nav button { + background-color: transparent; + color: var(--hpe-green); + border: 1px solid var(--hpe-green); + border-radius: 4px; + padding: 4px 12px; + font-weight: 500; + transition: all 0.2s ease; +} + +nav button:hover { + background-color: var(--hpe-green); + color: var(--hpe-white); +} + +/* Icon colors */ +.fill-white\/50 { + fill: var(--hpe-gray-medium); +} + +.fill-white\/75 { + fill: var(--hpe-gray-dark); +} + +.fill-white\/90 { + fill: var(--hpe-gray-dark); +} + +.fill-white { + fill: var(--hpe-white); +} + +/* Icon hover states */ +.hover\:fill-white\/90:hover { + fill: var(--hpe-green); +} + +.hover\:fill-white\/75:hover { + fill: var(--hpe-green); +} + +/* Code blocks - HPE style */ +:not(pre)>code { + background: var(--hpe-gray-lightest); + border: 1px solid var(--hpe-gray-lighter); + border-radius: 3px; + padding: 2px 6px; margin: 2px; + color: var(--hpe-gray-dark); + font-size: 0.9em; } -pre > code { +pre>code { width: 100%; display: flex; overflow-x: scroll; max-width: 100%; + color: var(--hpe-gray-lightest); } pre { - background: rgba(255, 255, 255, 0.05); + background: var(--hpe-dark-secondary); + border: 1px solid var(--hpe-gray-lighter); border-radius: 4px; margin: 1em 0em; max-width: 100%; - padding: 1em + padding: 1em; + color: var(--hpe-gray-lightest); } +/* Tables - HPE style */ table { margin: 1em 0; text-align: left; - border: rgba(255,255,255,0.05) solid; - border-radius: 8px; - background: rgba(255,255,255,0.05); + border: 1px solid var(--hpe-gray-lighter); + border-radius: 4px; + background: var(--hpe-white); + overflow: hidden; } +th { + background-color: var(--hpe-gray-lightest); + color: var(--hpe-gray-dark); + font-weight: 600; + padding: 8px 12px; + border-bottom: 1px solid var(--hpe-gray-lighter); +} + +td { + padding: 8px 12px; + border-bottom: 1px solid var(--hpe-gray-lighter); + color: var(--hpe-gray-dark); +} + +tr:last-child td { + border-bottom: none; +} + +/* Links - HPE style */ a[href] { - color: #70b1ff; + color: var(--hpe-green); font-weight: 500; + text-decoration: none; + transition: color 0.2s ease; } -th, td { - padding: 4px, 8px; +a[href]:hover { + color: var(--hpe-green-dark); + text-decoration: underline; } +/* List items */ li { - line-height: 1em; + line-height: 1.5em; + color: var(--hpe-gray-dark); +} + +/* Command menu styling */ +.bg-white\/10 { + background-color: var(--hpe-gray-lightest); + border: 1px solid var(--hpe-gray-light); +} + +/* Focus states */ +.focus\:ring-white\/10:focus { + --tw-ring-color: var(--hpe-green); +} + +/* Menu toggle SVG */ +svg path { + stroke: var(--hpe-gray-dark); +} + +/* Add padding to messages container */ +.overflow-scroll { + padding: 20px; + background-color: var(--hpe-gray-lightest); +} + +/* Style the main chat area */ +.flex-col.items-center.justify-end { + background-color: var(--hpe-gray-lightest); + margin: 20px; + border-radius: 8px; +} + +/* Timestamp styling */ +.text-xs.text-white\/50 { + color: var(--hpe-gray-medium); + font-size: 11px; +} + +/* Markdown content styling */ +.text-sm.text-white { + color: var(--hpe-gray-dark); + font-size: 14px; + line-height: 1.6; +} + +/* Scrollbar styling for HPE theme */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--hpe-gray-lightest); +} + +::-webkit-scrollbar-thumb { + background: var(--hpe-gray-light); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--hpe-gray-medium); +} + +/* Animation adjustments */ +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +/* Loading states */ +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* Ensure proper contrast for accessibility */ +.placeholder\:text-white\/80::placeholder { + color: var(--hpe-gray-medium); +} + +.prose { + color: inherit; +} + +.prose a { + color: #01a982; + text-decoration: none; +} + +.prose a:hover { + text-decoration: underline; +} + +.prose code { + background-color: rgba(0, 0, 0, 0.05); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + font-size: 0.875em; +} + +.prose pre { + background-color: #1a1a1a; + color: #e8e8e8; + padding: 1rem; + border-radius: 0.5rem; + overflow-x: auto; +} + +.prose pre code { + background-color: transparent; + padding: 0; +} + +/* Smooth transitions */ +* { + transition-property: background-color, border-color; + transition-duration: 200ms; + transition-timing-function: ease-in-out; +} + +/* Custom scrollbar for chat area */ +.overflow-y-auto::-webkit-scrollbar { + width: 6px; +} + +.overflow-y-auto::-webkit-scrollbar-track { + background: #f7f7f7; +} + +.overflow-y-auto::-webkit-scrollbar-thumb { + background: #d1d1d1; + border-radius: 3px; +} + +.overflow-y-auto::-webkit-scrollbar-thumb:hover { + background: #767676; +} + +/* Input focus styles */ +input:focus, +textarea:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(1, 169, 130, 0.1); +} + +/* Animation for menu toggle */ +@keyframes slideIn { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + } + + to { + transform: translateX(-100%); + } } \ No newline at end of file diff --git a/src/app/layout.tsx b/src/app/layout.tsx index fde23fb..e75e2e4 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -4,6 +4,7 @@ import "./globals.css"; import ModalSelector from "@/components/modal/modal-selector"; import { ModalProvider } from "./context/ModalContext"; import { PromptsProvider } from "./context/PromptContext"; +import { SystemInstructionProvider } from "./context/SystemInstructionContext"; const montserrat = Montserrat({ subsets: ["latin"] }); @@ -21,8 +22,10 @@ export default function RootLayout({ - - {children} + + + {children} + diff --git a/src/app/page.tsx b/src/app/page.tsx index e529e87..a0ef138 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,20 +9,30 @@ import { SaveIcon } from "@/components/icons/save-icon"; import { TrashIcon } from "@/components/icons/trash-icon"; import Sidebar from "@/components/sidebar"; import { cn } from "@/utils/cn"; -import { baseUrl, fallbackModel } from "@/utils/constants"; +import { baseUrl, fallbackModel, vectorSearchBaseUrl } from "@/utils/constants"; import generateRandomString from "@/utils/generateRandomString"; import { useCycle } from "framer-motion"; import { ChatOllama } from "langchain/chat_models/ollama"; -import { AIMessage, HumanMessage } from "langchain/schema"; +import { AIMessage, HumanMessage, SystemMessage } from "langchain/schema"; import { useEffect, useRef, useState } from "react"; import Markdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { AppModal, useModal } from "./context/ModalContext"; import { usePrompts } from "./context/PromptContext"; +import { useSystemInstruction } from "./context/SystemInstructionContext"; +import { getSystemInstructionById } from "@/utils/systemInstructions"; + +// Define the DocumentEntry type +type DocumentEntry = { + filename: string; + guid: string; + selected?: boolean; +}; export default function Home() { const { setModalConfig } = useModal(); const { activePromptTemplate, setActivePromptTemplate } = usePrompts(); + const { activeSystemInstruction } = useSystemInstruction(); const [newPrompt, setNewPrompt] = useState(""); const [messages, setMessages] = useState< { @@ -36,13 +46,25 @@ export default function Home() { const [availableModels, setAvailableModels] = useState([]); const [activeModel, setActiveModel] = useState(""); const [ollama, setOllama] = useState(); - const [conversations, setConversations] = useState< - { title: string; filePath: string }[] - >([]); + const [conversations, setConversations] = useState<{ title: string; filePath: string }[]>([]); const [activeConversation, setActiveConversation] = useState(""); const [menuState, toggleMenuState] = useCycle(false, true); + const [selectedDocuments, setSelectedDocuments] = useState([]); + const [filteredDocuments, setFilteredDocuments] = useState([]); const msgContainerRef = useRef(null); + useEffect(() => { + console.log("All selectedDocuments:", selectedDocuments); + console.log("Documents with selected=true:", selectedDocuments.filter(doc => doc.selected === true)); + console.log("Documents with selected=false:", selectedDocuments.filter(doc => doc.selected === false)); + console.log("Documents with selected=undefined:", selectedDocuments.filter(doc => doc.selected === undefined)); + + // Filter to show only selected documents in the right panel + const selected = selectedDocuments.filter(doc => doc.selected === true); + setFilteredDocuments(selected); + console.log("Setting filteredDocuments to:", selected); + }, [selectedDocuments]); + useEffect(() => { scrollToBottom(); }, [activeConversation]); @@ -53,17 +75,77 @@ export default function Home() { // Get existing conversations getExistingConvos(); + + // Set up window.setDocumentValues function + setupWindowDocumentValues(); }, []); + // Function to handle document values from vector search + function setupWindowDocumentValues() { + // @ts-ignore + window.setDocumentValues = (documents: DocumentEntry[] | DocumentEntry | string, guid?: string) => { + console.log('window.setDocumentValues called with:', documents); + + if (typeof documents === 'string' && guid) { + const newEntry: DocumentEntry = { + filename: documents, + guid: guid, + selected: true + }; + setSelectedDocuments(prevDocs => { + // Remove any existing document with the same guid first + const filtered = prevDocs.filter(doc => doc.guid !== guid); + return [...filtered, newEntry]; + }); + } else if (Array.isArray(documents)) { + setSelectedDocuments(prevDocs => { + // Create a map of existing documents + const existingMap = new Map(prevDocs.map(doc => [doc.guid, doc])); + + // Update or add new documents + documents.forEach(newDoc => { + existingMap.set(newDoc.guid, { ...newDoc, selected: true }); + }); + + return Array.from(existingMap.values()); + }); + } else if (typeof documents === 'object') { + const doc = documents as DocumentEntry; + setSelectedDocuments(prevDocs => { + // Remove any existing document with the same guid first + const filtered = prevDocs.filter(d => d.guid !== doc.guid); + return [...filtered, { ...doc, selected: true }]; + }); + } + }; + + return () => { + // @ts-ignore + delete window.setDocumentValues; + }; + } + function getInitialModel() { fetch(`${baseUrl}/api/tags`) - .then((response) => response.json()) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + return response.json(); + }) .then((data) => { - // console.log(data); + console.log("Available models from API:", data); + + if (!data.models || data.models.length === 0) { + console.error("No models available from Ollama"); + return; + } + setAvailableModels(data.models); - // get initial model from local storage const storedModel = localStorage.getItem("initialLocalLM"); + let modelToUse = null; + if ( storedModel && storedModel !== "" && @@ -72,27 +154,43 @@ export default function Home() { m.name.toLowerCase() === storedModel.toLowerCase(), ) > -1 ) { - setActiveModel(storedModel); + modelToUse = storedModel; + } else { + modelToUse = data.models[0]?.name; + } + + console.log("Setting active model to:", modelToUse); + setActiveModel(modelToUse); + + if (modelToUse) { const newOllama = new ChatOllama({ baseUrl: baseUrl, - model: storedModel, + model: modelToUse, }); + console.log("Created Ollama instance:", newOllama); setOllama(newOllama); - } else { - // set initial model to first model in list - setActiveModel(data.models[0]?.name); - const initOllama = new ChatOllama({ - baseUrl: baseUrl, - model: data.models[0]?.name, - }); - setOllama(initOllama); } + }) + .catch((error) => { + console.error("Error fetching models:", error); + // Set a fallback model if the API fails + const fallbackModelName = fallbackModel || "llama2"; + console.log("Using fallback model:", fallbackModelName); + + setActiveModel(fallbackModelName); + setAvailableModels([{ name: fallbackModelName }]); + + const newOllama = new ChatOllama({ + baseUrl: baseUrl, + model: fallbackModelName, + }); + setOllama(newOllama); }); } async function getExistingConvos() { fetch("../api/fs/get-convos", { - method: "POST", // or 'GET', 'PUT', etc. + method: "POST", body: JSON.stringify({ conversationPath: "./conversations", }), @@ -101,65 +199,190 @@ export default function Home() { "Content-Type": "application/json", }, }).then((response) => { - // console.log(response), response.json().then((data) => setConversations(data)); }); } + async function performVectorSearch(query: string) { + try { + const selectedDocs = selectedDocuments.filter(doc => doc.selected); + + let response; + + if (selectedDocs.length > 0) { + const document_guids = selectedDocs.map(doc => doc.guid); + + response = await fetch(`${vectorSearchBaseUrl}/v1.0/vector/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + query: query, + document_guids: document_guids, + limit: 10 + }) + }); + } else { + response = await fetch(`${vectorSearchBaseUrl}/v1.0/vector/router/search`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify({ + query: query, + document_guid: "f49f92f7-8cdf-4f44-b327-0519e2aad881", + limit: 1 + }) + }); + } + + if (!response.ok) { + console.error('Vector search failed:', response.statusText); + return null; + } + + const data = await response.json(); + return data.results; + } catch (error) { + console.error('Error performing vector search:', error); + return null; + } + } + async function triggerPrompt(input: string = newPrompt) { - if (!ollama) return; + console.log("triggerPrompt called with:", input); + console.log("ollama:", ollama); + console.log("activeModel:", activeModel); + + if (!ollama) { + console.error("Ollama is not initialized!"); + return; + } + scrollToBottom(); - if (messages.length == 0) getName(input); + + if (messages.length === 0) { + console.log("First message, getting name..."); + getName(input); + } + const msg = { type: "human", id: generateRandomString(8), timestamp: Date.now(), content: input, }; + + console.log("Creating message:", msg); + const model = activeModel; let streamedText = ""; - messages.push(msg); - const msgCache = [...messages]; - const stream = await ollama.stream( - messages.map((m) => - m.type == "human" - ? new HumanMessage(m.content) - : new AIMessage(m.content), - ), - ); - setNewPrompt(""); - setActivePromptTemplate(undefined); - let updatedMessages = [...msgCache]; - let c = 0; - for await (const chunk of stream) { - streamedText += chunk.content; - const aiMsg = { + + // Update messages state properly + const msgCache = [...messages, msg]; + setMessages(msgCache); + + console.log("Messages after push:", msgCache); + + // First perform vector search + console.log("Performing vector search..."); + const vectorSearchResults = await performVectorSearch(input); + console.log("Vector search results:", vectorSearchResults); + + // Prepare context from vector search results + let contextFromVectorSearch = ""; + if (vectorSearchResults && vectorSearchResults.length > 0) { + if (window.setDocumentValues) { + const documentEntries: DocumentEntry[] = vectorSearchResults.map((result: any) => ({ + filename: decodeURIComponent(result.object_key), + guid: result.document_guid, + selected: true + })); + + window.setDocumentValues(documentEntries); + } + + contextFromVectorSearch = "Context from vector search:\n" + + vectorSearchResults.map((result: any) => result.content).join("\n") + + "\n\nUser query: " + input; + } else { + contextFromVectorSearch = input; + } + + // Use the context in the request to Ollama + const selectedDocs = selectedDocuments.filter(doc => doc.selected); + const systemInstruction = selectedDocs.length === 0 + ? getSystemInstructionById("concise") || activeSystemInstruction + : activeSystemInstruction; + + console.log(`Using ${systemInstruction.name} system instruction`); + + const messagesWithSystemInstruction = [ + new SystemMessage(systemInstruction.content), + ...msgCache.map((m, index) => { + if (index === msgCache.length - 1 && m.type === "human") { + return new HumanMessage(contextFromVectorSearch); + } else { + return m.type === "human" + ? new HumanMessage(m.content) + : new AIMessage(m.content); + } + }) + ]; + + try { + const stream = await ollama.stream(messagesWithSystemInstruction); + + setNewPrompt(""); + setActivePromptTemplate(undefined); + + let updatedMessages = [...msgCache]; + let c = 0; + + for await (const chunk of stream) { + streamedText += chunk.content; + const aiMsg = { + type: "ai", + id: generateRandomString(8), + timestamp: Date.now(), + content: streamedText, + model, + }; + updatedMessages = [...msgCache, aiMsg]; + setMessages(updatedMessages); + c++; + if (c % 8 == 0) scrollToBottom(); + } + + scrollToBottom(); + persistConvo(updatedMessages); + } catch (error) { + console.error("Error streaming from Ollama:", error); + // Optionally add an error message to the chat + const errorMsg = { type: "ai", id: generateRandomString(8), timestamp: Date.now(), - content: streamedText, + content: "Sorry, I encountered an error while processing your request. Please try again.", model, }; - updatedMessages = [...msgCache, aiMsg]; - setMessages(() => updatedMessages); - c++; - if (c % 8 == 0) scrollToBottom(); + const updatedMessages = [...msgCache, errorMsg]; + setMessages(updatedMessages); } - - scrollToBottom(); - persistConvo(updatedMessages); } async function persistConvo(messages: any[]) { let name = activeConversation; if (name == "") { name = (await getName(newPrompt)).trim(); - // console.log(name.trim()); setActiveConversation(name.trim()); } fetch("../api/fs/persist-convo", { - method: "POST", // or 'GET', 'PUT', etc. + method: "POST", body: JSON.stringify({ conversationPath: "./conversations", messages: messages, @@ -198,21 +421,52 @@ export default function Home() { messages.findIndex((m) => m.id == activeMsg.id) - (activeMsg.type == "human" ? 0 : 1); let filtered = messages.filter((m, i) => index >= i); - // console.log("filtered", filtered); setMessages(() => filtered); - // useEffect on change here if the last value was a human message? const model = activeModel; let streamedText = ""; const msgCache = [...filtered]; - const stream = await ollama.stream( - filtered.map((m) => - m.type == "human" - ? new HumanMessage(m.content) - : new AIMessage(m.content), - ), - ); + + const lastHumanMessage = filtered.filter(m => m.type === "human").pop(); + let vectorSearchResults: any[] | null = null; + + if (lastHumanMessage) { + vectorSearchResults = await performVectorSearch(lastHumanMessage.content); + + if (vectorSearchResults && vectorSearchResults.length > 0 && window.setDocumentValues) { + const documentEntries: DocumentEntry[] = vectorSearchResults.map((result: any) => ({ + filename: decodeURIComponent(result.object_key), + guid: result.document_guid, + selected: true + })); + + window.setDocumentValues(documentEntries); + } + } + + const selectedDocs = selectedDocuments.filter(doc => doc.selected); + const systemInstruction = selectedDocs.length === 0 + ? getSystemInstructionById("concise") || activeSystemInstruction + : activeSystemInstruction; + + const messagesForOllama = [ + new SystemMessage(systemInstruction.content), + ...filtered.map((m, index) => { + if (index === filtered.length - 1 && m.type === "human" && vectorSearchResults && vectorSearchResults.length > 0) { + const contextFromVectorSearch = "Context from vector search:\n" + + vectorSearchResults.map((result: any) => result.content).join("\n") + + "\n\nUser query: " + m.content; + return new HumanMessage(contextFromVectorSearch); + } else { + return m.type === "human" + ? new HumanMessage(m.content) + : new AIMessage(m.content); + } + }) + ]; + + const stream = await ollama.stream(messagesForOllama); setNewPrompt(""); let updatedMessages = [...msgCache]; let c = 0; @@ -252,171 +506,350 @@ export default function Home() { }); return nameOllama! .predict( - "You're a tool, that receives an input and responds exclusively with a 2-5 word summary of the topic (and absolutely no prose) based specifically on the words used in the input (not the expected output). Each word in the summary should be carefully chosen so that it's perfecly informative - and serve as a perfect title for the input. Now, return the summary for the following input:\n" + - input, + "You're a tool, that receives an input and responds exclusively with a 2-5 word summary of the topic for the HPE Partner Portal (and absolutely no prose) based specifically on the words used in the input (not the expected output). Each word in the summary should be carefully chosen so that it's perfectly informative - and serve as a perfect title for the input. Now, return the summary for the following input:\n" + + input, ) .then((name) => name); } return ( -
- -
- {}} - activeModel={activeModel} - availableModels={availableModels} - setActiveModel={setActiveModel} - setOllama={setOllama} +
+
+ {/* Top section */} +
+ {/* Home icon (already correct) */} + + + {/* Archive/Box icon */} + + + {/* Puzzle piece icon */} + +
+ + {/* Spacer */} +
+ + {/* Bottom section */} +
+ {/* Target/Crosshair icon */} + + + {/* Wrench/Tool icon */} + + + {/* Link/Chain icon */} + + + {/* Chart/Analytics icon */} + + + {/* Document/Clipboard icon */} + + + {/* Settings/Gear icon */} + +
+
+
+ setSelectedDocuments([])} + setDocumentEntries={(documents) => { + if (Array.isArray(documents) && documents.length === 0) { + setSelectedDocuments([]); + } else if (typeof documents === 'string' && arguments.length > 1) { + const guid = arguments[1]; + const newEntry = { + filename: documents, + guid: guid, + selected: true + }; + setSelectedDocuments([newEntry]); + } else if (Array.isArray(documents)) { + setSelectedDocuments(documents); + } else { + if (typeof documents === 'string') { + console.error("Expected DocumentEntry, received string:", documents); + setSelectedDocuments([]); + } else { + setSelectedDocuments([documents]); + } + } + }} + toggleMenuState={toggleMenuState} /> -
-
-
- {messages.map((msg) => ( +
+ { }} + activeModel={activeModel} + availableModels={availableModels} + setActiveModel={setActiveModel} + setOllama={setOllama} + selectedDocuments={selectedDocuments} + onDocumentToggle={(index) => { + const updatedDocuments = [...selectedDocuments]; + updatedDocuments[index] = { + ...updatedDocuments[index], + selected: updatedDocuments[index].selected === false ? true : false + }; + setSelectedDocuments(updatedDocuments); + }} + /> + + {/* Main content area with documents panel */} +
+ {/* Chat area */} +
+
-
+ {messages.length === 0 ? ( +
+
+

Welcome to the HPE Partner Portal AI Assistant

+

Start a conversation to begin by telling us what system you're interested in getting some information about.

+

That will help the system narrow down the scope and provide the best responses

+

You can then select which documents you wish to research from the panel on the right

+
+
+ ) : ( +
+ {messages.map((msg) => ( +
+
+
+ + {msg?.model?.split(":")[0] || "You"} •{" "} + {new Date(msg.timestamp).toLocaleTimeString()} + +
+ + {msg.content.trim()} + +
+ {msg.type === "human" && ( + { + setModalConfig({ + modal: AppModal.SAVE_PROMPT, + data: msg, + }); + }} + className="h-4 w-4 cursor-pointer fill-current opacity-60 hover:opacity-100" + /> + )} + refreshMessage(msg)} + className="h-4 w-4 cursor-pointer fill-current opacity-60 hover:opacity-100" + /> + { + navigator.clipboard.writeText(msg.content); + }} + className="h-4 w-4 cursor-pointer fill-current opacity-60 hover:opacity-100" + /> + { + deleteMessage(msg); + }} + className="h-4 w-4 cursor-pointer fill-current opacity-60 hover:opacity-100" + /> +
+
+
+ ))} +
)} - > -

- {(msg?.model?.split(":")[0] || "user") + - " • " + - new Date(msg.timestamp).toLocaleDateString() + - " " + - new Date(msg.timestamp).toLocaleTimeString()} -

- - {msg.content.trim()} -
-
- {msg.type == "human" && ( - { - setModalConfig({ - modal: AppModal.SAVE_PROMPT, - data: msg, - }); - }} - className="h-4 w-4 fill-white/50 hover:fill-white/90" - /> - )} - refreshMessage(msg)} - className="h-4 w-4 fill-white/50 hover:fill-white/90" - /> - { - navigator.clipboard.writeText(msg.content); - }} - className="h-4 w-4 fill-white/50 hover:fill-white/90" - /> - { - deleteMessage(msg); - }} - className="h-4 w-4 fill-white/50 hover:fill-white/90" +
+ +
+
+ +
+ {activePromptTemplate ? ( + { + if ( + x.e.key === "Enter" && + !x.e.metaKey && + !x.e.shiftKey && + !x.e.altKey && + newPrompt !== "" + ) { + triggerPrompt(x.input); + } + }} + /> + ) : ( + { + if (e.target.value != "\n") setNewPrompt(e.target.value); + }} + onKeyDown={(e) => { + if ( + e.key === "Enter" && + !e.metaKey && + !e.shiftKey && + !e.altKey && + newPrompt !== "" + ) { + e.preventDefault(); // Add this line + triggerPrompt(); + } + }} + value={newPrompt} + placeholder="Send a message" + className="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-gray-700 placeholder-gray-500 focus:border-[#01a982] focus:outline-none focus:ring-2 focus:ring-[#01a982] focus:ring-opacity-50" + /> + )} +
- ))} +
-
-
-
- - {/* TODO: Include Active Prompt Template when selected above so we know what's beind done or insert placeholder input as it's being populated */} -
- {activePromptTemplate ? ( - <> - { - if ( - x.e.key === "Enter" && - !x.e.metaKey && - !x.e.shiftKey && - !x.e.altKey && - newPrompt !== "" - ) { - triggerPrompt(x.input); - } - }} - /> - - ) : ( - { - if (e.target.value != "\n") setNewPrompt(e.target.value); - }} - onKeyDown={(e) => { - if ( - e.key === "Enter" && - !e.metaKey && - !e.shiftKey && - !e.altKey && - newPrompt !== "" - ) { - triggerPrompt(); - } else if ( - e.key === "Enter" && - (e.metaKey || !e.shiftKey || !e.altKey) - ) { - // console.log(e); + {/* Documents Panel - Always visible */} +
+
+

Selected Documents

+

+ {filteredDocuments.length > 0 + ? `${filteredDocuments.length} document${filteredDocuments.length > 1 ? 's' : ''} selected` + : 'No documents selected' } - }} - value={newPrompt} - placeholder="Send a message" - /> - )} +

+
+ + {/* Documents List */} +
+ {filteredDocuments.length > 0 ? ( +
+ {filteredDocuments.map((entry) => ( +
+ {/* Document Icon */} + + + + + {/* Document Info */} +
+
{entry.filename}
+ {/*
ID: {entry.guid.substring(0, 8)}...
*/} +
+ + {/* Remove button */} + +
+ ))} +
+ ) : ( +
+ + + +

No documents selected

+

Select documents from the dropdown above

+
+ )} +
+
-
-
+
+ ); -} +} \ No newline at end of file diff --git a/src/components/app-navbar.tsx b/src/components/app-navbar.tsx index a0dedf8..be6d0da 100644 --- a/src/components/app-navbar.tsx +++ b/src/components/app-navbar.tsx @@ -3,6 +3,14 @@ import { ChatOllama } from "langchain/chat_models/ollama"; import { baseUrl } from "@/utils/constants"; import { useEffect, useRef, useState } from "react"; +import { useSystemInstruction } from "@/app/context/SystemInstructionContext"; +import { AppModal, useModal } from "@/app/context/ModalContext"; + +type DocumentEntry = { + filename: string; + guid: string; + selected?: boolean; +}; type Props = { documentName: string; @@ -11,6 +19,8 @@ type Props = { availableModels: any[]; setActiveModel: Function; setOllama: Function; + selectedDocuments: DocumentEntry[]; + onDocumentToggle: (index: number) => void; }; export default function AppNavbar({ @@ -20,31 +30,38 @@ export default function AppNavbar({ availableModels, setActiveModel, setOllama, + selectedDocuments, + onDocumentToggle, }: Props) { - const [isShareMenuOpen, setIsShareMenuOpen] = useState(false); - const [isProfileMenuOpen, setIsProfileMenuOpen] = useState(false); + const [isSystemMenuOpen, setIsSystemMenuOpen] = useState(false); + const [isDocumentMenuOpen, setIsDocumentMenuOpen] = useState(false); + const { activeSystemInstruction, allSystemInstructions, setActiveSystemInstructionById } = useSystemInstruction(); + const { setModalConfig } = useModal(); + + const systemMenuRef = useRef(null); + const documentMenuRef = useRef(null); const handleInputChange = (event: React.ChangeEvent) => { const value = event.target.value; - setDocumentName(value); // Call the callback function to update the parent component + setDocumentName(value); }; useEffect(() => { const handleDocumentClick = (event: any) => { if ( - isShareMenuOpen && - shareMenuRef.current && - !shareMenuRef.current.contains(event.target) + isSystemMenuOpen && + systemMenuRef.current && + !systemMenuRef.current.contains(event.target) ) { - setIsShareMenuOpen(false); + setIsSystemMenuOpen(false); } if ( - isProfileMenuOpen && - profileMenuRef.current && - !profileMenuRef.current.contains(event.target) + isDocumentMenuOpen && + documentMenuRef.current && + !documentMenuRef.current.contains(event.target) ) { - setIsProfileMenuOpen(false); + setIsDocumentMenuOpen(false); } }; @@ -53,7 +70,7 @@ export default function AppNavbar({ return () => { document.removeEventListener("click", handleDocumentClick); }; - }, [isShareMenuOpen, isProfileMenuOpen]); + }, [isSystemMenuOpen, isDocumentMenuOpen]); function toggleModel() { const i = @@ -65,35 +82,178 @@ export default function AppNavbar({ baseUrl: baseUrl, model: availableModels[i]?.name, }); - //store in local storage localStorage.setItem("initialLocalLM", availableModels[i]?.name); setOllama(newOllama); } - const shareMenuRef = useRef(null); - const profileMenuRef = useRef(null); - return ( - <> - ); -} +} \ No newline at end of file diff --git a/src/components/menu-toggle.tsx b/src/components/menu-toggle.tsx index 23d39de..c6434c4 100644 --- a/src/components/menu-toggle.tsx +++ b/src/components/menu-toggle.tsx @@ -11,14 +11,16 @@ const Path = (props: any) => ( /> ); -export const MenuToggle = ({ toggle }: any) => ( - + + + + {/* Modal Content */} +
+
+ {/* Instruction Name Field */} +
+ + +
+ + {/* Instruction Content Field */} +
+ +