feat(agent): Refactor search agents and implement SpeedSearchAgent
- Updated FileSearchAgent to improve code readability and formatting. - Refactored SynthesizerAgent for better prompt handling and document processing. - Enhanced TaskManagerAgent with clearer file context handling. - Modified AgentSearch to maintain consistent parameter formatting. - Introduced SpeedSearchAgent for optimized search functionality. - Updated metaSearchAgent to support new SpeedSearchAgent. - Improved file processing utilities for better document handling. - Added test attachments for sporting events queries.
This commit is contained in:
parent
de3d26fb15
commit
d66300e78e
19 changed files with 832 additions and 996 deletions
|
|
@ -43,7 +43,7 @@ type EmbeddingModel = {
|
|||
|
||||
type Body = {
|
||||
message: Message;
|
||||
optimizationMode: 'speed' | 'balanced' | 'agent';
|
||||
optimizationMode: 'speed' | 'agent';
|
||||
focusMode: string;
|
||||
history: Array<[string, string]>;
|
||||
files: Array<string>;
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ interface embeddingModel {
|
|||
}
|
||||
|
||||
interface ChatRequestBody {
|
||||
optimizationMode: 'speed' | 'balanced' | 'agent';
|
||||
optimizationMode: 'speed' | 'agent';
|
||||
focusMode: string;
|
||||
chatModel?: chatModel;
|
||||
embeddingModel?: embeddingModel;
|
||||
|
|
@ -52,7 +52,7 @@ export const POST = async (req: Request) => {
|
|||
}
|
||||
|
||||
body.history = body.history || [];
|
||||
body.optimizationMode = body.optimizationMode || 'balanced';
|
||||
body.optimizationMode = body.optimizationMode || 'speed';
|
||||
body.stream = body.stream || false;
|
||||
|
||||
const history: BaseMessage[] = body.history.map((msg) => {
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ const MessageInput = ({
|
|||
setFileIds={setFileIds}
|
||||
files={files}
|
||||
setFiles={setFiles}
|
||||
optimizationMode={optimizationMode}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center space-x-2">
|
||||
|
|
|
|||
|
|
@ -14,16 +14,23 @@ const Attach = ({
|
|||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
optimizationMode,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: FileType[];
|
||||
setFiles: (files: FileType[]) => void;
|
||||
optimizationMode: string;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
const isSpeedMode = optimizationMode === 'speed';
|
||||
const isDisabled = isSpeedMode;
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (isDisabled) return;
|
||||
|
||||
setLoading(true);
|
||||
const data = new FormData();
|
||||
|
||||
|
|
@ -37,7 +44,8 @@ const Attach = ({
|
|||
const embeddingModel = localStorage.getItem('embeddingModel');
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
const ollamaContextWindow = localStorage.getItem('ollamaContextWindow') || '2048';
|
||||
const ollamaContextWindow =
|
||||
localStorage.getItem('ollamaContextWindow') || '2048';
|
||||
|
||||
data.append('embedding_model_provider', embeddingModelProvider!);
|
||||
data.append('embedding_model', embeddingModel!);
|
||||
|
|
@ -67,122 +75,166 @@ const Attach = ({
|
|||
</p>
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-between space-x-1 p-2 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white',
|
||||
files.length > 0 ? '-ml-2 lg:-ml-3' : '',
|
||||
)}
|
||||
>
|
||||
{files.length > 1 && (
|
||||
<>
|
||||
<File size={19} className="text-sky-400" />
|
||||
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium">
|
||||
{files.length} files
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="relative group">
|
||||
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex flex-row items-center justify-between space-x-1 p-2 rounded-xl transition duration-200',
|
||||
files.length > 0 ? '-ml-2 lg:-ml-3' : '',
|
||||
isDisabled
|
||||
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
|
||||
: 'text-black/50 dark:text-white/50 hover:bg-light-secondary dark:hover:bg-dark-secondary hover:text-black dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
{files.length > 1 && (
|
||||
<>
|
||||
<File size={19} className={isDisabled ? 'text-sky-900' : 'text-sky-400'} />
|
||||
<p className={cn("inline whitespace-nowrap text-xs font-medium", isDisabled ? 'text-sky-900' : 'text-sky-400')}>
|
||||
{files.length} files
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{files.length === 1 && (
|
||||
<>
|
||||
<File size={18} className="text-sky-400" />
|
||||
<p className="text-sky-400 text-xs font-medium">
|
||||
{files[0].fileName.length > 10
|
||||
? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) +
|
||||
'...' +
|
||||
files[0].fileExtension
|
||||
: files[0].fileName}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
{files.length === 1 && (
|
||||
<>
|
||||
<File size={18} className={isDisabled ? 'text-sky-900' : 'text-sky-400'} />
|
||||
<p className={cn("text-xs font-medium", isDisabled ? 'text-sky-900' : 'text-sky-400')}>
|
||||
{files[0].fileName.length > 10
|
||||
? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) +
|
||||
'...' +
|
||||
files[0].fileExtension
|
||||
: files[0].fileName}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isDisabled && fileInputRef.current.click()}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1 transition duration-200',
|
||||
isDisabled
|
||||
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
|
||||
: 'text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isDisabled) {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1 transition duration-200',
|
||||
isDisabled
|
||||
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
|
||||
: 'text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white',
|
||||
)}
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
{isSpeedMode && (
|
||||
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
||||
<div className="bg-black dark:bg-white text-white dark:text-black text-xs px-2 py-1 rounded whitespace-nowrap">
|
||||
File attachments are disabled in Speed mode
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-white"></div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white p-2',
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Paperclip size="18" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !isDisabled && fileInputRef.current.click()}
|
||||
disabled={isDisabled}
|
||||
className={cn(
|
||||
'flex flex-row items-center space-x-1 rounded-xl transition duration-200 p-2',
|
||||
isDisabled
|
||||
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
|
||||
: 'text-black/50 dark:text-white/50 hover:bg-light-secondary dark:hover:bg-dark-secondary hover:text-black dark:hover:text-white',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<Paperclip size="18" />
|
||||
</button>
|
||||
{isSpeedMode && (
|
||||
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
|
||||
<div className="bg-black dark:bg-white text-white dark:text-black text-xs px-2 py-1 rounded whitespace-nowrap">
|
||||
File attachments are disabled in Speed mode
|
||||
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-white"></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,161 +0,0 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Popover,
|
||||
PopoverButton,
|
||||
PopoverPanel,
|
||||
Transition,
|
||||
} from '@headlessui/react';
|
||||
import { CopyPlus, File, LoaderCircle, Plus, Trash } from 'lucide-react';
|
||||
import { Fragment, useRef, useState } from 'react';
|
||||
import { File as FileType } from '../ChatWindow';
|
||||
|
||||
const AttachSmall = ({
|
||||
fileIds,
|
||||
setFileIds,
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
fileIds: string[];
|
||||
setFileIds: (fileIds: string[]) => void;
|
||||
files: FileType[];
|
||||
setFiles: (files: FileType[]) => void;
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const fileInputRef = useRef<any>();
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setLoading(true);
|
||||
const data = new FormData();
|
||||
|
||||
for (let i = 0; i < e.target.files!.length; i++) {
|
||||
data.append('files', e.target.files![i]);
|
||||
}
|
||||
|
||||
const embeddingModelProvider = localStorage.getItem(
|
||||
'embeddingModelProvider',
|
||||
);
|
||||
const embeddingModel = localStorage.getItem('embeddingModel');
|
||||
const chatModelProvider = localStorage.getItem('chatModelProvider');
|
||||
const chatModel = localStorage.getItem('chatModel');
|
||||
const ollamaContextWindow = localStorage.getItem('ollamaContextWindow') || '2048';
|
||||
|
||||
data.append('embedding_model_provider', embeddingModelProvider!);
|
||||
data.append('embedding_model', embeddingModel!);
|
||||
data.append('chat_model_provider', chatModelProvider!);
|
||||
data.append('chat_model', chatModel!);
|
||||
if (chatModelProvider === 'ollama') {
|
||||
data.append('ollama_context_window', ollamaContextWindow);
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/uploads`, {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
});
|
||||
|
||||
const resData = await res.json();
|
||||
|
||||
setFiles([...files, ...resData.files]);
|
||||
setFileIds([...fileIds, ...resData.files.map((file: any) => file.fileId)]);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return loading ? (
|
||||
<div className="flex flex-row items-center justify-between space-x-1 p-1">
|
||||
<LoaderCircle size={20} className="text-sky-400 animate-spin" />
|
||||
</div>
|
||||
) : files.length > 0 ? (
|
||||
<Popover className="max-w-[15rem] md:max-w-md lg:max-w-lg">
|
||||
<PopoverButton
|
||||
type="button"
|
||||
className="flex flex-row items-center justify-between space-x-1 p-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
|
||||
>
|
||||
<File size={20} className="text-sky-400" />
|
||||
</PopoverButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-150"
|
||||
enterFrom="opacity-0 translate-y-1"
|
||||
enterTo="opacity-100 translate-y-0"
|
||||
leave="transition ease-in duration-150"
|
||||
leaveFrom="opacity-100 translate-y-0"
|
||||
leaveTo="opacity-0 translate-y-1"
|
||||
>
|
||||
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] bottom-14 -ml-3">
|
||||
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
|
||||
<div className="flex flex-row items-center justify-between px-3 py-2">
|
||||
<h4 className="text-black dark:text-white font-medium text-sm">
|
||||
Attached files
|
||||
</h4>
|
||||
<div className="flex flex-row items-center space-x-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<Plus size={18} />
|
||||
<p className="text-xs">Add</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setFiles([]);
|
||||
setFileIds([]);
|
||||
}}
|
||||
className="flex flex-row items-center space-x-1 text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white transition duration-200"
|
||||
>
|
||||
<Trash size={14} />
|
||||
<p className="text-xs">Clear</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[0.5px] mx-2 bg-white/10" />
|
||||
<div className="flex flex-col items-center">
|
||||
{files.map((file, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
|
||||
>
|
||||
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
|
||||
<File size={16} className="text-white/70" />
|
||||
</div>
|
||||
<p className="text-black/70 dark:text-white/70 text-sm">
|
||||
{file.fileName.length > 25
|
||||
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
|
||||
'...' +
|
||||
file.fileExtension
|
||||
: file.fileName}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverPanel>
|
||||
</Transition>
|
||||
</Popover>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current.click()}
|
||||
className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white p-1"
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
onChange={handleChange}
|
||||
ref={fileInputRef}
|
||||
accept=".pdf,.docx,.txt"
|
||||
multiple
|
||||
hidden
|
||||
/>
|
||||
<CopyPlus size={20} />
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachSmall;
|
||||
|
|
@ -12,7 +12,7 @@ const OptimizationModes = [
|
|||
key: 'speed',
|
||||
title: 'Speed',
|
||||
description:
|
||||
'Prioritize speed and get the quickest possible answer. Minimum effort retrieving web content.',
|
||||
'Prioritize speed and get the quickest possible answer. Uses only web search results - attached files will not be processed.',
|
||||
icon: <Zap size={20} className="text-[#FF9800]" />,
|
||||
},
|
||||
// {
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ const RouterDecisionSchema = z.object({
|
|||
decision: z
|
||||
.enum(['file_search', 'web_search', 'analyzer'])
|
||||
.describe('The next step to take in the workflow'),
|
||||
reasoning: z
|
||||
.string()
|
||||
.describe('Explanation of why this decision was made'),
|
||||
reasoning: z.string().describe('Explanation of why this decision was made'),
|
||||
});
|
||||
|
||||
type RouterDecision = z.infer<typeof RouterDecisionSchema>;
|
||||
|
|
@ -57,13 +55,15 @@ export class ContentRouterAgent {
|
|||
|
||||
// Extract focus mode from state - this should now come from the API
|
||||
const focusMode = state.focusMode || 'webSearch';
|
||||
|
||||
|
||||
const hasFiles = state.fileIds && state.fileIds.length > 0;
|
||||
const documentCount = state.relevantDocuments.length;
|
||||
const searchHistory = state.searchInstructionHistory.join(', ') || 'None';
|
||||
|
||||
|
||||
// Extract file topics if files are available
|
||||
const fileTopics = hasFiles ? await this.extractFileTopics(state.fileIds!) : 'None';
|
||||
const fileTopics = hasFiles
|
||||
? await this.extractFileTopics(state.fileIds!)
|
||||
: 'None';
|
||||
|
||||
// Emit routing decision event
|
||||
this.emitter.emit('agent_action', {
|
||||
|
|
@ -97,9 +97,12 @@ export class ContentRouterAgent {
|
|||
});
|
||||
|
||||
// Use structured output for routing decision
|
||||
const structuredLlm = this.llm.withStructuredOutput(RouterDecisionSchema, {
|
||||
name: 'route_content',
|
||||
});
|
||||
const structuredLlm = this.llm.withStructuredOutput(
|
||||
RouterDecisionSchema,
|
||||
{
|
||||
name: 'route_content',
|
||||
},
|
||||
);
|
||||
|
||||
const routerDecision = await structuredLlm.invoke(
|
||||
[...removeThinkingBlocksFromMessages(state.messages), prompt],
|
||||
|
|
@ -112,7 +115,11 @@ export class ContentRouterAgent {
|
|||
console.log(`Focus mode: ${focusMode}`);
|
||||
|
||||
// Validate decision based on focus mode restrictions
|
||||
const validatedDecision = this.validateDecision(routerDecision, focusMode, hasFiles);
|
||||
const validatedDecision = this.validateDecision(
|
||||
routerDecision,
|
||||
focusMode,
|
||||
hasFiles,
|
||||
);
|
||||
|
||||
// Emit routing result event
|
||||
this.emitter.emit('agent_action', {
|
||||
|
|
@ -163,15 +170,15 @@ export class ContentRouterAgent {
|
|||
*/
|
||||
private async extractFileTopics(fileIds: string[]): Promise<string> {
|
||||
try {
|
||||
const topics = fileIds.map(fileId => {
|
||||
const topics = fileIds.map((fileId) => {
|
||||
try {
|
||||
const filePath = path.join(process.cwd(), 'uploads', fileId);
|
||||
const contentPath = filePath + '-extracted.json';
|
||||
|
||||
|
||||
if (fs.existsSync(contentPath)) {
|
||||
const content = JSON.parse(fs.readFileSync(contentPath, 'utf8'));
|
||||
const filename = content.title || 'Document';
|
||||
|
||||
|
||||
// Use LLM-generated semantic topics if available, otherwise fall back to filename
|
||||
const semanticTopics = content.topics;
|
||||
return semanticTopics || filename;
|
||||
|
|
@ -182,7 +189,7 @@ export class ContentRouterAgent {
|
|||
return 'Unknown Document';
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return topics.join('; ');
|
||||
} catch (error) {
|
||||
console.warn('Error extracting file topics:', error);
|
||||
|
|
@ -199,16 +206,17 @@ export class ContentRouterAgent {
|
|||
hasFiles: boolean,
|
||||
): RouterDecision {
|
||||
// Enforce focus mode restrictions for chat and localResearch modes
|
||||
if ((focusMode === 'chat' || focusMode === 'localResearch') &&
|
||||
decision.decision === 'web_search') {
|
||||
|
||||
if (
|
||||
(focusMode === 'chat' || focusMode === 'localResearch') &&
|
||||
decision.decision === 'web_search'
|
||||
) {
|
||||
// Override to file_search if files are available, otherwise analyzer
|
||||
const fallbackDecision = hasFiles ? 'file_search' : 'analyzer';
|
||||
|
||||
|
||||
console.log(
|
||||
`Overriding web_search decision to ${fallbackDecision} due to focus mode restriction: ${focusMode}`
|
||||
`Overriding web_search decision to ${fallbackDecision} due to focus mode restriction: ${focusMode}`,
|
||||
);
|
||||
|
||||
|
||||
return {
|
||||
decision: fallbackDecision as 'file_search' | 'analyzer',
|
||||
reasoning: `Overridden to ${fallbackDecision} - web search not allowed in ${focusMode} mode. ${decision.reasoning}`,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,10 @@ import { EventEmitter } from 'events';
|
|||
import { Document } from 'langchain/document';
|
||||
import { AgentState } from './agentState';
|
||||
import { Embeddings } from '@langchain/core/embeddings';
|
||||
import { processFilesToDocuments, getRankedDocs } from '../utils/fileProcessing';
|
||||
import {
|
||||
processFilesToDocuments,
|
||||
getRankedDocs,
|
||||
} from '../utils/fileProcessing';
|
||||
|
||||
export class FileSearchAgent {
|
||||
private llm: BaseChatModel;
|
||||
|
|
@ -79,12 +82,16 @@ export class FileSearchAgent {
|
|||
return new Command({
|
||||
goto: 'analyzer',
|
||||
update: {
|
||||
messages: [new AIMessage('No searchable content found in attached files.')],
|
||||
messages: [
|
||||
new AIMessage('No searchable content found in attached files.'),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Processed ${fileDocuments.length} file documents for search`);
|
||||
console.log(
|
||||
`Processed ${fileDocuments.length} file documents for search`,
|
||||
);
|
||||
|
||||
// Emit searching file content event
|
||||
this.emitter.emit('agent_action', {
|
||||
|
|
@ -139,7 +146,11 @@ export class FileSearchAgent {
|
|||
return new Command({
|
||||
goto: 'analyzer',
|
||||
update: {
|
||||
messages: [new AIMessage('No relevant content found in attached files for the current task.')],
|
||||
messages: [
|
||||
new AIMessage(
|
||||
'No relevant content found in attached files for the current task.',
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -157,7 +168,8 @@ export class FileSearchAgent {
|
|||
totalTasks: state.tasks?.length || 1,
|
||||
relevantSections: rankedDocuments.length,
|
||||
searchedDocuments: fileDocuments.length,
|
||||
documentCount: state.relevantDocuments.length + rankedDocuments.length,
|
||||
documentCount:
|
||||
state.relevantDocuments.length + rankedDocuments.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -33,23 +33,22 @@ export class SynthesizerAgent {
|
|||
try {
|
||||
// Format the prompt using the external template
|
||||
const template = PromptTemplate.fromTemplate(synthesizerPrompt);
|
||||
|
||||
const conversationHistory = removeThinkingBlocksFromMessages(state.messages)
|
||||
.map((msg) => `<${msg.getType()}>${msg.content}</${msg.getType()}>`)
|
||||
.join('\n') || 'No previous conversation context';
|
||||
|
||||
const conversationHistory =
|
||||
removeThinkingBlocksFromMessages(state.messages)
|
||||
.map((msg) => `<${msg.getType()}>${msg.content}</${msg.getType()}>`)
|
||||
.join('\n') || 'No previous conversation context';
|
||||
|
||||
const relevantDocuments = state.relevantDocuments
|
||||
.map(
|
||||
(doc, index) => {
|
||||
const isFile = doc.metadata?.url?.toLowerCase().includes('file');
|
||||
return `<${index + 1}>\n
|
||||
.map((doc, index) => {
|
||||
const isFile = doc.metadata?.url?.toLowerCase().includes('file');
|
||||
return `<${index + 1}>\n
|
||||
<title>${doc.metadata.title}</title>\n
|
||||
<source_type>${isFile ? 'file' : 'web'}</source_type>\n
|
||||
${isFile ? '' : '\n<url>' + doc.metadata.url + '</url>\n'}
|
||||
<content>\n${doc.pageContent}\n</content>\n
|
||||
</${index + 1}>`;
|
||||
}
|
||||
)
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const formattedPrompt = await template.format({
|
||||
|
|
|
|||
|
|
@ -127,12 +127,13 @@ export class TaskManagerAgent {
|
|||
});
|
||||
|
||||
const template = PromptTemplate.fromTemplate(taskBreakdownPrompt);
|
||||
|
||||
|
||||
// Create file context information
|
||||
const fileContext = state.fileIds && state.fileIds.length > 0
|
||||
? `Files attached: ${state.fileIds.length} file(s) are available for analysis. Consider creating tasks that can leverage these attached files when appropriate.`
|
||||
: 'No files attached: Focus on tasks that can be answered through web research or general knowledge.';
|
||||
|
||||
const fileContext =
|
||||
state.fileIds && state.fileIds.length > 0
|
||||
? `Files attached: ${state.fileIds.length} file(s) are available for analysis. Consider creating tasks that can leverage these attached files when appropriate.`
|
||||
: 'No files attached: Focus on tasks that can be answered through web research or general knowledge.';
|
||||
|
||||
const prompt = await template.format({
|
||||
systemInstructions: this.systemInstructions,
|
||||
fileContext: fileContext,
|
||||
|
|
|
|||
|
|
@ -153,9 +153,9 @@ export class AgentSearch {
|
|||
* Execute the agent search workflow
|
||||
*/
|
||||
async searchAndAnswer(
|
||||
query: string,
|
||||
history: BaseMessage[] = [],
|
||||
fileIds: string[] = []
|
||||
query: string,
|
||||
history: BaseMessage[] = [],
|
||||
fileIds: string[] = [],
|
||||
) {
|
||||
const workflow = this.createWorkflow();
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import MetaSearchAgent from '@/lib/search/metaSearchAgent';
|
||||
import SpeedSearchAgent from '@/lib/search/speedSearch';
|
||||
import prompts from '../prompts';
|
||||
|
||||
export { default as SpeedSearchAgent } from './speedSearch';
|
||||
|
||||
export const searchHandlers: Record<string, MetaSearchAgent> = {
|
||||
webSearch: new MetaSearchAgent({
|
||||
activeEngines: [],
|
||||
|
|
|
|||
|
|
@ -1,32 +1,9 @@
|
|||
import type { Embeddings } from '@langchain/core/embeddings';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { BaseMessage } from '@langchain/core/messages';
|
||||
import { StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
MessagesPlaceholder,
|
||||
PromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import {
|
||||
RunnableLambda,
|
||||
RunnableMap,
|
||||
RunnableSequence,
|
||||
} from '@langchain/core/runnables';
|
||||
import { StreamEvent } from '@langchain/core/tracers/log_stream';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import eventEmitter from 'events';
|
||||
import { Document } from 'langchain/document';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import LineOutputParser from '../outputParsers/lineOutputParser';
|
||||
import LineListOutputParser from '../outputParsers/listLineOutputParser';
|
||||
import { searchSearxng } from '../searxng';
|
||||
import { formatDateForLLM } from '../utils';
|
||||
import computeSimilarity from '../utils/computeSimilarity';
|
||||
import { getDocumentsFromLinks, getWebContent } from '../utils/documents';
|
||||
import formatChatHistoryAsString from '../utils/formatHistory';
|
||||
import { getModelName } from '../utils/modelUtils';
|
||||
import { AgentSearch } from './agentSearch';
|
||||
import SpeedSearchAgent from './speedSearch';
|
||||
|
||||
export interface MetaSearchAgentType {
|
||||
searchAndAnswer: (
|
||||
|
|
@ -34,7 +11,7 @@ export interface MetaSearchAgentType {
|
|||
history: BaseMessage[],
|
||||
llm: BaseChatModel,
|
||||
embeddings: Embeddings,
|
||||
optimizationMode: 'speed' | 'balanced' | 'agent',
|
||||
optimizationMode: 'speed' | 'agent',
|
||||
fileIds: string[],
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
|
|
@ -54,623 +31,13 @@ interface Config {
|
|||
additionalSearchCriteria?: string;
|
||||
}
|
||||
|
||||
type BasicChainInput = {
|
||||
chat_history: BaseMessage[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
class MetaSearchAgent implements MetaSearchAgentType {
|
||||
private config: Config;
|
||||
private strParser = new StringOutputParser();
|
||||
private searchQuery?: string;
|
||||
private searxngUrl?: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a progress event with the given percentage and message
|
||||
*/
|
||||
private emitProgress(
|
||||
emitter: eventEmitter,
|
||||
percentage: number,
|
||||
message: string,
|
||||
subMessage?: string,
|
||||
) {
|
||||
const progressData: any = {
|
||||
message,
|
||||
current: percentage,
|
||||
total: 100,
|
||||
};
|
||||
|
||||
// Add subMessage if provided
|
||||
if (subMessage) {
|
||||
progressData.subMessage = subMessage;
|
||||
}
|
||||
|
||||
emitter.emit(
|
||||
'progress',
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: progressData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async createSearchRetrieverChain(
|
||||
llm: BaseChatModel,
|
||||
systemInstructions: string,
|
||||
emitter: eventEmitter,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
// TODO: Don't we want to set this back to default once search is done?
|
||||
(llm as unknown as ChatOpenAI).temperature = 0;
|
||||
|
||||
this.emitProgress(emitter, 10, `Building search query`);
|
||||
|
||||
return RunnableSequence.from([
|
||||
PromptTemplate.fromTemplate(this.config.queryGeneratorPrompt),
|
||||
llm,
|
||||
this.strParser,
|
||||
RunnableLambda.from(async (input: string) => {
|
||||
try {
|
||||
//console.log(`LLM response for initial web search:"${input}"`);
|
||||
const linksOutputParser = new LineListOutputParser({
|
||||
key: 'links',
|
||||
});
|
||||
|
||||
const questionOutputParser = new LineOutputParser({
|
||||
key: 'answer',
|
||||
});
|
||||
|
||||
const links = await linksOutputParser.parse(input);
|
||||
let question = await questionOutputParser.parse(input);
|
||||
|
||||
//console.log('question', question);
|
||||
|
||||
if (question === 'not_needed') {
|
||||
return { query: '', docs: [] };
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
if (question.length === 0) {
|
||||
question = 'summarize';
|
||||
}
|
||||
|
||||
let docs: Document[] = [];
|
||||
|
||||
const linkDocs = await getDocumentsFromLinks({ links });
|
||||
|
||||
const docGroups: Document[] = [];
|
||||
|
||||
linkDocs.map((doc) => {
|
||||
const URLDocExists = docGroups.find(
|
||||
(d) =>
|
||||
d.metadata.url === doc.metadata.url &&
|
||||
d.metadata.totalDocs < 10,
|
||||
);
|
||||
|
||||
if (!URLDocExists) {
|
||||
docGroups.push({
|
||||
...doc,
|
||||
metadata: {
|
||||
...doc.metadata,
|
||||
totalDocs: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const docIndex = docGroups.findIndex(
|
||||
(d) =>
|
||||
d.metadata.url === doc.metadata.url &&
|
||||
d.metadata.totalDocs < 10,
|
||||
);
|
||||
|
||||
if (docIndex !== -1) {
|
||||
docGroups[docIndex].pageContent =
|
||||
docGroups[docIndex].pageContent + `\n\n` + doc.pageContent;
|
||||
docGroups[docIndex].metadata.totalDocs += 1;
|
||||
}
|
||||
});
|
||||
|
||||
this.emitProgress(emitter, 20, `Summarizing content`);
|
||||
|
||||
await Promise.all(
|
||||
docGroups.map(async (doc) => {
|
||||
const systemPrompt = systemInstructions
|
||||
? `${systemInstructions}\n\n`
|
||||
: '';
|
||||
|
||||
const res = await llm.invoke(
|
||||
`${systemPrompt}You are a web search summarizer, tasked with summarizing a piece of text retrieved from a web search. Your job is to summarize the
|
||||
text into a detailed, 2-4 paragraph explanation that captures the main ideas and provides a comprehensive answer to the query.
|
||||
If the query is \"summarize\", you should provide a detailed summary of the text. If the query is a specific question, you should answer it in the summary.
|
||||
|
||||
- **Journalistic tone**: The summary should sound professional and journalistic, not too casual or vague.
|
||||
- **Thorough and detailed**: Ensure that every key point from the text is captured and that the summary directly answers the query.
|
||||
- **Not too lengthy, but detailed**: The summary should be informative but not excessively long. Focus on providing detailed information in a concise format.
|
||||
|
||||
The text will be shared inside the \`text\` XML tag, and the query inside the \`query\` XML tag.
|
||||
|
||||
<example>
|
||||
1. \`<text>
|
||||
Docker is a set of platform-as-a-service products that use OS-level virtualization to deliver software in packages called containers.
|
||||
It was first released in 2013 and is developed by Docker, Inc. Docker is designed to make it easier to create, deploy, and run applications
|
||||
by using containers.
|
||||
</text>
|
||||
|
||||
<query>
|
||||
What is Docker and how does it work?
|
||||
</query>
|
||||
|
||||
Response:
|
||||
Docker is a revolutionary platform-as-a-service product developed by Docker, Inc., that uses container technology to make application
|
||||
deployment more efficient. It allows developers to package their software with all necessary dependencies, making it easier to run in
|
||||
any environment. Released in 2013, Docker has transformed the way applications are built, deployed, and managed.
|
||||
\`
|
||||
2. \`<text>
|
||||
The theory of relativity, or simply relativity, encompasses two interrelated theories of Albert Einstein: special relativity and general
|
||||
relativity. However, the word "relativity" is sometimes used in reference to Galilean invariance. The term "theory of relativity" was based
|
||||
on the expression "relative theory" used by Max Planck in 1906. The theory of relativity usually encompasses two interrelated theories by
|
||||
Albert Einstein: special relativity and general relativity. Special relativity applies to all physical phenomena in the absence of gravity.
|
||||
General relativity explains the law of gravitation and its relation to other forces of nature. It applies to the cosmological and astrophysical
|
||||
realm, including astronomy.
|
||||
</text>
|
||||
|
||||
<query>
|
||||
summarize
|
||||
</query>
|
||||
|
||||
Response:
|
||||
The theory of relativity, developed by Albert Einstein, encompasses two main theories: special relativity and general relativity. Special
|
||||
relativity applies to all physical phenomena in the absence of gravity, while general relativity explains the law of gravitation and its
|
||||
relation to other forces of nature. The theory of relativity is based on the concept of "relative theory," as introduced by Max Planck in
|
||||
1906. It is a fundamental theory in physics that has revolutionized our understanding of the universe.
|
||||
\`
|
||||
</example>
|
||||
|
||||
Everything below is the actual data you will be working with. Good luck!
|
||||
|
||||
<query>
|
||||
${question}
|
||||
</query>
|
||||
|
||||
<text>
|
||||
${doc.pageContent}
|
||||
</text>
|
||||
|
||||
Make sure to answer the query in the summary.
|
||||
`,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const document = new Document({
|
||||
pageContent: res.content as string,
|
||||
metadata: {
|
||||
title: doc.metadata.title,
|
||||
url: doc.metadata.url,
|
||||
},
|
||||
});
|
||||
|
||||
docs.push(document);
|
||||
}),
|
||||
);
|
||||
|
||||
return { query: question, docs: docs };
|
||||
} else {
|
||||
if (this.config.additionalSearchCriteria) {
|
||||
question = `${question} ${this.config.additionalSearchCriteria}`;
|
||||
}
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
20,
|
||||
`Searching the web`,
|
||||
`Search Query: ${question}`,
|
||||
);
|
||||
|
||||
const searxngResult = await searchSearxng(question, {
|
||||
language: 'en',
|
||||
engines: this.config.activeEngines,
|
||||
});
|
||||
|
||||
// Store the SearXNG URL for later use in emitting to the client
|
||||
this.searxngUrl = searxngResult.searchUrl;
|
||||
|
||||
const documents = searxngResult.results.map(
|
||||
(result) =>
|
||||
new Document({
|
||||
pageContent:
|
||||
result.content ||
|
||||
(this.config.activeEngines.includes('youtube')
|
||||
? result.title
|
||||
: '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */,
|
||||
metadata: {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
...(result.img_src && { img_src: result.img_src }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { query: question, docs: documents, searchQuery: question };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in search retriever chain:', error);
|
||||
emitter.emit('error', JSON.stringify({ data: error }));
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async createAnsweringChain(
|
||||
llm: BaseChatModel,
|
||||
fileIds: string[],
|
||||
embeddings: Embeddings,
|
||||
optimizationMode: 'speed' | 'balanced' | 'agent',
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
emitter: eventEmitter,
|
||||
personaInstructions?: string,
|
||||
) {
|
||||
return RunnableSequence.from([
|
||||
RunnableMap.from({
|
||||
systemInstructions: () => systemInstructions,
|
||||
query: (input: BasicChainInput) => input.query,
|
||||
chat_history: (input: BasicChainInput) => input.chat_history,
|
||||
date: () => formatDateForLLM(),
|
||||
personaInstructions: () => personaInstructions || '',
|
||||
context: RunnableLambda.from(
|
||||
async (
|
||||
input: BasicChainInput,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => {
|
||||
// Check if the request was aborted
|
||||
if (options?.signal?.aborted || signal?.aborted) {
|
||||
console.log('Request cancelled by user');
|
||||
throw new Error('Request cancelled by user');
|
||||
}
|
||||
|
||||
const processedHistory = formatChatHistoryAsString(
|
||||
input.chat_history,
|
||||
);
|
||||
|
||||
let docs: Document[] | null = null;
|
||||
let query = input.query;
|
||||
|
||||
if (this.config.searchWeb) {
|
||||
const searchRetrieverChain =
|
||||
await this.createSearchRetrieverChain(
|
||||
llm,
|
||||
systemInstructions,
|
||||
emitter,
|
||||
signal,
|
||||
);
|
||||
var date = formatDateForLLM();
|
||||
|
||||
const searchRetrieverResult = await searchRetrieverChain.invoke(
|
||||
{
|
||||
chat_history: processedHistory,
|
||||
query,
|
||||
date,
|
||||
systemInstructions,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
);
|
||||
|
||||
query = searchRetrieverResult.query;
|
||||
docs = searchRetrieverResult.docs;
|
||||
|
||||
// Store the search query in the context for emitting to the client
|
||||
if (searchRetrieverResult.searchQuery) {
|
||||
this.searchQuery = searchRetrieverResult.searchQuery;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedDocs = await this.rerankDocs(
|
||||
query,
|
||||
docs ?? [],
|
||||
fileIds,
|
||||
embeddings,
|
||||
optimizationMode,
|
||||
llm,
|
||||
systemInstructions,
|
||||
emitter,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (options?.signal?.aborted || signal?.aborted) {
|
||||
console.log('Request cancelled by user');
|
||||
throw new Error('Request cancelled by user');
|
||||
}
|
||||
|
||||
this.emitProgress(emitter, 100, `Done`);
|
||||
return sortedDocs;
|
||||
},
|
||||
)
|
||||
.withConfig({
|
||||
runName: 'FinalSourceRetriever',
|
||||
})
|
||||
.pipe(this.processDocs),
|
||||
}),
|
||||
ChatPromptTemplate.fromMessages([
|
||||
['system', this.config.responsePrompt],
|
||||
new MessagesPlaceholder('chat_history'),
|
||||
['user', '{query}'],
|
||||
]),
|
||||
llm,
|
||||
this.strParser,
|
||||
]).withConfig({
|
||||
runName: 'FinalResponseGenerator',
|
||||
});
|
||||
}
|
||||
|
||||
private async rerankDocs(
|
||||
query: string,
|
||||
docs: Document[],
|
||||
fileIds: string[],
|
||||
embeddings: Embeddings,
|
||||
optimizationMode: 'speed' | 'balanced' | 'agent',
|
||||
llm: BaseChatModel,
|
||||
systemInstructions: string,
|
||||
emitter: eventEmitter,
|
||||
signal: AbortSignal,
|
||||
): Promise<Document[]> {
|
||||
try {
|
||||
if (docs.length === 0 && fileIds.length === 0) {
|
||||
return docs;
|
||||
}
|
||||
|
||||
if (query.toLocaleLowerCase() === 'summarize') {
|
||||
return docs.slice(0, 15);
|
||||
}
|
||||
|
||||
const filesData = fileIds
|
||||
.map((file) => {
|
||||
const filePath = path.join(process.cwd(), 'uploads', file);
|
||||
|
||||
const contentPath = filePath + '-extracted.json';
|
||||
const embeddingsPath = filePath + '-embeddings.json';
|
||||
|
||||
const content = JSON.parse(fs.readFileSync(contentPath, 'utf8'));
|
||||
const embeddings = JSON.parse(
|
||||
fs.readFileSync(embeddingsPath, 'utf8'),
|
||||
);
|
||||
|
||||
const fileSimilaritySearchObject = content.contents.map(
|
||||
(c: string, i: number) => {
|
||||
return {
|
||||
fileName: content.title,
|
||||
content: c,
|
||||
embeddings: embeddings.embeddings[i],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return fileSimilaritySearchObject;
|
||||
})
|
||||
.flat();
|
||||
|
||||
let docsWithContent = docs.filter(
|
||||
(doc) => doc.pageContent && doc.pageContent.length > 0,
|
||||
);
|
||||
|
||||
const queryEmbedding = await embeddings.embedQuery(query);
|
||||
|
||||
const getRankedDocs = async (
|
||||
queryEmbedding: number[],
|
||||
includeFiles: boolean,
|
||||
includeNonFileDocs: boolean,
|
||||
maxDocs: number,
|
||||
) => {
|
||||
let docsToRank = includeNonFileDocs ? docsWithContent : [];
|
||||
|
||||
if (includeFiles) {
|
||||
// Add file documents to the ranking
|
||||
const fileDocs = filesData.map((fileData) => {
|
||||
return new Document({
|
||||
pageContent: fileData.content,
|
||||
metadata: {
|
||||
title: fileData.fileName,
|
||||
url: `File`,
|
||||
embeddings: fileData.embeddings,
|
||||
},
|
||||
});
|
||||
});
|
||||
docsToRank.push(...fileDocs);
|
||||
}
|
||||
|
||||
const similarity = await Promise.all(
|
||||
docsToRank.map(async (doc, i) => {
|
||||
const sim = computeSimilarity(
|
||||
queryEmbedding,
|
||||
doc.metadata?.embeddings
|
||||
? doc.metadata?.embeddings
|
||||
: (await embeddings.embedDocuments([doc.pageContent]))[0],
|
||||
);
|
||||
return {
|
||||
index: i,
|
||||
similarity: sim,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
let rankedDocs = similarity
|
||||
.filter(
|
||||
(sim) => sim.similarity > (this.config.rerankThreshold ?? 0.3),
|
||||
)
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.map((sim) => docsToRank[sim.index]);
|
||||
|
||||
rankedDocs =
|
||||
docsToRank.length > 0 ? rankedDocs.slice(0, maxDocs) : rankedDocs;
|
||||
return rankedDocs;
|
||||
};
|
||||
if (optimizationMode === 'speed' || this.config.rerank === false) {
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
50,
|
||||
`Ranking sources`,
|
||||
this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined,
|
||||
);
|
||||
if (filesData.length > 0) {
|
||||
const sortedFiles = await getRankedDocs(
|
||||
queryEmbedding,
|
||||
true,
|
||||
false,
|
||||
8,
|
||||
);
|
||||
|
||||
return [
|
||||
...sortedFiles,
|
||||
...docsWithContent.slice(0, 15 - sortedFiles.length),
|
||||
];
|
||||
} else {
|
||||
return docsWithContent.slice(0, 15);
|
||||
}
|
||||
} else if (optimizationMode === 'balanced') {
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
40,
|
||||
`Ranking sources`,
|
||||
this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined,
|
||||
);
|
||||
// Get the top ranked attached files, if any
|
||||
let sortedDocs = await getRankedDocs(queryEmbedding, true, false, 8);
|
||||
|
||||
sortedDocs = [
|
||||
...sortedDocs,
|
||||
...docsWithContent.slice(0, 15 - sortedDocs.length),
|
||||
];
|
||||
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
60,
|
||||
`Enriching sources`,
|
||||
this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined,
|
||||
);
|
||||
sortedDocs = await Promise.all(
|
||||
sortedDocs.map(async (doc) => {
|
||||
const webContent = await getWebContent(doc.metadata.url);
|
||||
const chunks =
|
||||
webContent?.pageContent
|
||||
.match(/.{1,500}/g)
|
||||
?.map((chunk) => chunk.trim()) || [];
|
||||
const chunkEmbeddings = await embeddings.embedDocuments(chunks);
|
||||
const similarities = chunkEmbeddings.map((chunkEmbedding) => {
|
||||
return computeSimilarity(queryEmbedding, chunkEmbedding);
|
||||
});
|
||||
|
||||
const topChunks = similarities
|
||||
.map((similarity, index) => ({ similarity, index }))
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.slice(0, 5)
|
||||
.map((chunk) => chunks[chunk.index]);
|
||||
const excerpt = topChunks.join('\n\n');
|
||||
|
||||
let newDoc = {
|
||||
...doc,
|
||||
pageContent: excerpt
|
||||
? `${excerpt}\n\n${doc.pageContent}`
|
||||
: doc.pageContent,
|
||||
};
|
||||
return newDoc;
|
||||
}),
|
||||
);
|
||||
|
||||
return sortedDocs;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in rerankDocs:', error);
|
||||
emitter.emit('error', JSON.stringify({ data: error }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private processDocs(docs: Document[]) {
|
||||
const fullDocs = docs
|
||||
.map(
|
||||
(_, index) =>
|
||||
`<${index + 1}>\n
|
||||
<title>${docs[index].metadata.title}</title>\n
|
||||
${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + docs[index].metadata.url + '</url>\n'}
|
||||
<content>\n${docs[index].pageContent}\n</content>\n
|
||||
</${index + 1}>\n`,
|
||||
)
|
||||
.join('\n');
|
||||
console.log('Processed docs:', fullDocs);
|
||||
return fullDocs;
|
||||
}
|
||||
|
||||
private async handleStream(
|
||||
stream: AsyncGenerator<StreamEvent, any, any>,
|
||||
emitter: eventEmitter,
|
||||
llm: BaseChatModel,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const event of stream) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.event === 'on_chain_end' &&
|
||||
event.name === 'FinalSourceRetriever'
|
||||
) {
|
||||
const sourcesData = event.data.output;
|
||||
if (this.searchQuery) {
|
||||
emitter.emit(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
type: 'sources',
|
||||
data: sourcesData,
|
||||
searchQuery: this.searchQuery,
|
||||
searchUrl: this.searxngUrl,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
emitter.emit(
|
||||
'data',
|
||||
JSON.stringify({ type: 'sources', data: sourcesData }),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.event === 'on_chain_stream' &&
|
||||
event.name === 'FinalResponseGenerator'
|
||||
) {
|
||||
emitter.emit(
|
||||
'data',
|
||||
JSON.stringify({ type: 'response', data: event.data.chunk }),
|
||||
);
|
||||
}
|
||||
if (
|
||||
event.event === 'on_chain_end' &&
|
||||
event.name === 'FinalResponseGenerator'
|
||||
) {
|
||||
const modelName = getModelName(llm);
|
||||
|
||||
// Send model info before ending
|
||||
emitter.emit(
|
||||
'stats',
|
||||
JSON.stringify({
|
||||
type: 'modelStats',
|
||||
data: {
|
||||
modelName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
emitter.emit('end');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute agent workflow asynchronously with proper streaming support
|
||||
*/
|
||||
|
|
@ -719,7 +86,7 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + do
|
|||
history: BaseMessage[],
|
||||
llm: BaseChatModel,
|
||||
embeddings: Embeddings,
|
||||
optimizationMode: 'speed' | 'balanced' | 'agent',
|
||||
optimizationMode: 'speed' | 'agent',
|
||||
fileIds: string[],
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
|
|
@ -728,50 +95,35 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + do
|
|||
) {
|
||||
const emitter = new eventEmitter();
|
||||
|
||||
// Branch to agent search if optimization mode is 'agent'
|
||||
if (optimizationMode === 'agent') {
|
||||
// Execute agent workflow asynchronously to maintain streaming
|
||||
this.executeAgentWorkflow(
|
||||
llm,
|
||||
embeddings,
|
||||
emitter,
|
||||
// Branch to speed search if optimization mode is 'speed'
|
||||
if (optimizationMode === 'speed') {
|
||||
const speedSearchAgent = new SpeedSearchAgent(this.config);
|
||||
return speedSearchAgent.searchAndAnswer(
|
||||
message,
|
||||
history,
|
||||
fileIds,
|
||||
llm,
|
||||
embeddings,
|
||||
systemInstructions,
|
||||
personaInstructions || '',
|
||||
signal,
|
||||
focusMode || 'webSearch',
|
||||
personaInstructions,
|
||||
focusMode,
|
||||
);
|
||||
return emitter;
|
||||
}
|
||||
|
||||
// Existing logic for other optimization modes
|
||||
const answeringChain = await this.createAnsweringChain(
|
||||
// Execute agent workflow for 'agent' mode
|
||||
this.executeAgentWorkflow(
|
||||
llm,
|
||||
fileIds,
|
||||
embeddings,
|
||||
optimizationMode,
|
||||
systemInstructions,
|
||||
signal,
|
||||
emitter,
|
||||
personaInstructions,
|
||||
message,
|
||||
history,
|
||||
fileIds,
|
||||
systemInstructions,
|
||||
personaInstructions || '',
|
||||
signal,
|
||||
focusMode || 'webSearch',
|
||||
);
|
||||
|
||||
const stream = answeringChain.streamEvents(
|
||||
{
|
||||
chat_history: history,
|
||||
query: message,
|
||||
},
|
||||
{
|
||||
version: 'v1',
|
||||
// Pass the abort signal to the LLM streaming chain
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
this.handleStream(stream, emitter, llm, signal);
|
||||
|
||||
return emitter;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
560
src/lib/search/speedSearch.ts
Normal file
560
src/lib/search/speedSearch.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
import type { Embeddings } from '@langchain/core/embeddings';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { BaseMessage } from '@langchain/core/messages';
|
||||
import { StringOutputParser } from '@langchain/core/output_parsers';
|
||||
import {
|
||||
ChatPromptTemplate,
|
||||
MessagesPlaceholder,
|
||||
PromptTemplate,
|
||||
} from '@langchain/core/prompts';
|
||||
import {
|
||||
RunnableLambda,
|
||||
RunnableMap,
|
||||
RunnableSequence,
|
||||
} from '@langchain/core/runnables';
|
||||
import { StreamEvent } from '@langchain/core/tracers/log_stream';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import eventEmitter from 'events';
|
||||
import { Document } from 'langchain/document';
|
||||
import LineOutputParser from '../outputParsers/lineOutputParser';
|
||||
import LineListOutputParser from '../outputParsers/listLineOutputParser';
|
||||
import { searchSearxng } from '../searxng';
|
||||
import { formatDateForLLM } from '../utils';
|
||||
import { getDocumentsFromLinks } from '../utils/documents';
|
||||
import formatChatHistoryAsString from '../utils/formatHistory';
|
||||
import { getModelName } from '../utils/modelUtils';
|
||||
|
||||
export interface SpeedSearchAgentType {
|
||||
searchAndAnswer: (
|
||||
message: string,
|
||||
history: BaseMessage[],
|
||||
llm: BaseChatModel,
|
||||
embeddings: Embeddings,
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
personaInstructions?: string,
|
||||
focusMode?: string,
|
||||
) => Promise<eventEmitter>;
|
||||
}
|
||||
|
||||
interface Config {
|
||||
searchWeb: boolean;
|
||||
rerank: boolean;
|
||||
summarizer: boolean;
|
||||
rerankThreshold: number;
|
||||
queryGeneratorPrompt: string;
|
||||
responsePrompt: string;
|
||||
activeEngines: string[];
|
||||
additionalSearchCriteria?: string;
|
||||
}
|
||||
|
||||
type BasicChainInput = {
|
||||
chat_history: BaseMessage[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
class SpeedSearchAgent implements SpeedSearchAgentType {
|
||||
private config: Config;
|
||||
private strParser = new StringOutputParser();
|
||||
private searchQuery?: string;
|
||||
private searxngUrl?: string;
|
||||
|
||||
constructor(config: Config) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a progress event with the given percentage and message
|
||||
*/
|
||||
private emitProgress(
|
||||
emitter: eventEmitter,
|
||||
percentage: number,
|
||||
message: string,
|
||||
subMessage?: string,
|
||||
) {
|
||||
const progressData: any = {
|
||||
message,
|
||||
current: percentage,
|
||||
total: 100,
|
||||
};
|
||||
|
||||
// Add subMessage if provided
|
||||
if (subMessage) {
|
||||
progressData.subMessage = subMessage;
|
||||
}
|
||||
|
||||
emitter.emit(
|
||||
'progress',
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: progressData,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async createSearchRetrieverChain(
|
||||
llm: BaseChatModel,
|
||||
systemInstructions: string,
|
||||
emitter: eventEmitter,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
// TODO: Don't we want to set this back to default once search is done?
|
||||
(llm as unknown as ChatOpenAI).temperature = 0;
|
||||
|
||||
this.emitProgress(emitter, 10, `Building search query`);
|
||||
|
||||
return RunnableSequence.from([
|
||||
PromptTemplate.fromTemplate(this.config.queryGeneratorPrompt),
|
||||
llm,
|
||||
this.strParser,
|
||||
RunnableLambda.from(async (input: string) => {
|
||||
try {
|
||||
//console.log(`LLM response for initial web search:"${input}"`);
|
||||
const linksOutputParser = new LineListOutputParser({
|
||||
key: 'links',
|
||||
});
|
||||
|
||||
const questionOutputParser = new LineOutputParser({
|
||||
key: 'answer',
|
||||
});
|
||||
|
||||
const links = await linksOutputParser.parse(input);
|
||||
let question = await questionOutputParser.parse(input);
|
||||
|
||||
//console.log('question', question);
|
||||
|
||||
if (question === 'not_needed') {
|
||||
return { query: '', docs: [] };
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
if (question.length === 0) {
|
||||
question = 'summarize';
|
||||
}
|
||||
|
||||
let docs: Document[] = [];
|
||||
|
||||
const linkDocs = await getDocumentsFromLinks({ links });
|
||||
|
||||
const docGroups: Document[] = [];
|
||||
|
||||
linkDocs.map((doc) => {
|
||||
const URLDocExists = docGroups.find(
|
||||
(d) =>
|
||||
d.metadata.url === doc.metadata.url &&
|
||||
d.metadata.totalDocs < 10,
|
||||
);
|
||||
|
||||
if (!URLDocExists) {
|
||||
docGroups.push({
|
||||
...doc,
|
||||
metadata: {
|
||||
...doc.metadata,
|
||||
totalDocs: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const docIndex = docGroups.findIndex(
|
||||
(d) =>
|
||||
d.metadata.url === doc.metadata.url &&
|
||||
d.metadata.totalDocs < 10,
|
||||
);
|
||||
|
||||
if (docIndex !== -1) {
|
||||
docGroups[docIndex].pageContent =
|
||||
docGroups[docIndex].pageContent + `\n\n` + doc.pageContent;
|
||||
docGroups[docIndex].metadata.totalDocs += 1;
|
||||
}
|
||||
});
|
||||
|
||||
this.emitProgress(emitter, 20, `Summarizing content`);
|
||||
|
||||
await Promise.all(
|
||||
docGroups.map(async (doc) => {
|
||||
const systemPrompt = systemInstructions
|
||||
? `${systemInstructions}\n\n`
|
||||
: '';
|
||||
|
||||
const res = await llm.invoke(
|
||||
`${systemPrompt}You are a web search summarizer, tasked with summarizing a piece of text retrieved from a web search. Your job is to summarize the
|
||||
text into a detailed, 2-4 paragraph explanation that captures the main ideas and provides a comprehensive answer to the query.
|
||||
If the query is \"summarize\", you should provide a detailed summary of the text. If the query is a specific question, you should answer it in the summary.
|
||||
|
||||
- **Journalistic tone**: The summary should sound professional and journalistic, not too casual or vague.
|
||||
- **Thorough and detailed**: Ensure that every key point from the text is captured and that the summary directly answers the query.
|
||||
- **Not too lengthy, but detailed**: The summary should be informative but not excessively long. Focus on providing detailed information in a concise format.
|
||||
|
||||
The text will be shared inside the \`text\` XML tag, and the query inside the \`query\` XML tag.
|
||||
|
||||
<example>
|
||||
1. \`<text>
|
||||
Docker is a set of platform-as-a-service products that use OS-level virtualization to deliver software in packages called containers.
|
||||
It was first released in 2013 and is developed by Docker, Inc. Docker is designed to make it easier to create, deploy, and run applications
|
||||
by using containers.
|
||||
</text>
|
||||
|
||||
<query>
|
||||
What is Docker and how does it work?
|
||||
</query>
|
||||
|
||||
Response:
|
||||
Docker is a revolutionary platform-as-a-service product developed by Docker, Inc., that uses container technology to make application
|
||||
deployment more efficient. It allows developers to package their software with all necessary dependencies, making it easier to run in
|
||||
any environment. Released in 2013, Docker has transformed the way applications are built, deployed, and managed.
|
||||
\`
|
||||
2. \`<text>
|
||||
The theory of relativity, or simply relativity, encompasses two interrelated theories of Albert Einstein: special relativity and general
|
||||
relativity. However, the word "relativity" is sometimes used in reference to Galilean invariance. The term "theory of relativity" was based
|
||||
on the expression "relative theory" used by Max Planck in 1906. The theory of relativity usually encompasses two interrelated theories by
|
||||
Albert Einstein: special relativity and general relativity. Special relativity applies to all physical phenomena in the absence of gravity.
|
||||
General relativity explains the law of gravitation and its relation to other forces of nature. It applies to the cosmological and astrophysical
|
||||
realm, including astronomy.
|
||||
</text>
|
||||
|
||||
<query>
|
||||
summarize
|
||||
</query>
|
||||
|
||||
Response:
|
||||
The theory of relativity, developed by Albert Einstein, encompasses two main theories: special relativity and general relativity. Special
|
||||
relativity applies to all physical phenomena in the absence of gravity, while general relativity explains the law of gravitation and its
|
||||
relation to other forces of nature. The theory of relativity is based on the concept of "relative theory," as introduced by Max Planck in
|
||||
1906. It is a fundamental theory in physics that has revolutionized our understanding of the universe.
|
||||
\`
|
||||
</example>
|
||||
|
||||
Everything below is the actual data you will be working with. Good luck!
|
||||
|
||||
<query>
|
||||
${question}
|
||||
</query>
|
||||
|
||||
<text>
|
||||
${doc.pageContent}
|
||||
</text>
|
||||
|
||||
Make sure to answer the query in the summary.
|
||||
`,
|
||||
{ signal },
|
||||
);
|
||||
|
||||
const document = new Document({
|
||||
pageContent: res.content as string,
|
||||
metadata: {
|
||||
title: doc.metadata.title,
|
||||
url: doc.metadata.url,
|
||||
},
|
||||
});
|
||||
|
||||
docs.push(document);
|
||||
}),
|
||||
);
|
||||
|
||||
return { query: question, docs: docs };
|
||||
} else {
|
||||
if (this.config.additionalSearchCriteria) {
|
||||
question = `${question} ${this.config.additionalSearchCriteria}`;
|
||||
}
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
20,
|
||||
`Searching the web`,
|
||||
`Search Query: ${question}`,
|
||||
);
|
||||
|
||||
const searxngResult = await searchSearxng(question, {
|
||||
language: 'en',
|
||||
engines: this.config.activeEngines,
|
||||
});
|
||||
|
||||
// Store the SearXNG URL for later use in emitting to the client
|
||||
this.searxngUrl = searxngResult.searchUrl;
|
||||
|
||||
const documents = searxngResult.results.map(
|
||||
(result) =>
|
||||
new Document({
|
||||
pageContent:
|
||||
result.content ||
|
||||
(this.config.activeEngines.includes('youtube')
|
||||
? result.title
|
||||
: '') /* Todo: Implement transcript grabbing using Youtubei (source: https://www.npmjs.com/package/youtubei) */,
|
||||
metadata: {
|
||||
title: result.title,
|
||||
url: result.url,
|
||||
...(result.img_src && { img_src: result.img_src }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
return { query: question, docs: documents, searchQuery: question };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error in search retriever chain:', error);
|
||||
emitter.emit('error', JSON.stringify({ data: error }));
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async createAnsweringChain(
|
||||
llm: BaseChatModel,
|
||||
embeddings: Embeddings,
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
emitter: eventEmitter,
|
||||
personaInstructions?: string,
|
||||
) {
|
||||
return RunnableSequence.from([
|
||||
RunnableMap.from({
|
||||
systemInstructions: () => systemInstructions,
|
||||
query: (input: BasicChainInput) => input.query,
|
||||
chat_history: (input: BasicChainInput) => input.chat_history,
|
||||
date: () => formatDateForLLM(),
|
||||
personaInstructions: () => personaInstructions || '',
|
||||
context: RunnableLambda.from(
|
||||
async (
|
||||
input: BasicChainInput,
|
||||
options?: { signal?: AbortSignal },
|
||||
) => {
|
||||
// Check if the request was aborted
|
||||
if (options?.signal?.aborted || signal?.aborted) {
|
||||
console.log('Request cancelled by user');
|
||||
throw new Error('Request cancelled by user');
|
||||
}
|
||||
|
||||
const processedHistory = formatChatHistoryAsString(
|
||||
input.chat_history,
|
||||
);
|
||||
|
||||
let docs: Document[] | null = null;
|
||||
let query = input.query;
|
||||
|
||||
if (this.config.searchWeb) {
|
||||
const searchRetrieverChain =
|
||||
await this.createSearchRetrieverChain(
|
||||
llm,
|
||||
systemInstructions,
|
||||
emitter,
|
||||
signal,
|
||||
);
|
||||
var date = formatDateForLLM();
|
||||
|
||||
const searchRetrieverResult = await searchRetrieverChain.invoke(
|
||||
{
|
||||
chat_history: processedHistory,
|
||||
query,
|
||||
date,
|
||||
systemInstructions,
|
||||
},
|
||||
{ signal: options?.signal },
|
||||
);
|
||||
|
||||
query = searchRetrieverResult.query;
|
||||
docs = searchRetrieverResult.docs;
|
||||
|
||||
// Store the search query in the context for emitting to the client
|
||||
if (searchRetrieverResult.searchQuery) {
|
||||
this.searchQuery = searchRetrieverResult.searchQuery;
|
||||
}
|
||||
}
|
||||
|
||||
const sortedDocs = await this.rerankDocsForSpeed(
|
||||
query,
|
||||
docs ?? [],
|
||||
embeddings,
|
||||
emitter,
|
||||
signal,
|
||||
);
|
||||
|
||||
if (options?.signal?.aborted || signal?.aborted) {
|
||||
console.log('Request cancelled by user');
|
||||
throw new Error('Request cancelled by user');
|
||||
}
|
||||
|
||||
this.emitProgress(emitter, 100, `Done`);
|
||||
return sortedDocs;
|
||||
},
|
||||
)
|
||||
.withConfig({
|
||||
runName: 'FinalSourceRetriever',
|
||||
})
|
||||
.pipe(this.processDocs),
|
||||
}),
|
||||
ChatPromptTemplate.fromMessages([
|
||||
['system', this.config.responsePrompt],
|
||||
new MessagesPlaceholder('chat_history'),
|
||||
['user', '{query}'],
|
||||
]),
|
||||
llm,
|
||||
this.strParser,
|
||||
]).withConfig({
|
||||
runName: 'FinalResponseGenerator',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Speed-optimized document reranking with simplified logic for web results only
|
||||
*/
|
||||
private async rerankDocsForSpeed(
|
||||
query: string,
|
||||
docs: Document[],
|
||||
embeddings: Embeddings,
|
||||
emitter: eventEmitter,
|
||||
signal: AbortSignal,
|
||||
): Promise<Document[]> {
|
||||
try {
|
||||
if (docs.length === 0) {
|
||||
return docs;
|
||||
}
|
||||
|
||||
if (query.toLocaleLowerCase() === 'summarize') {
|
||||
return docs.slice(0, 15);
|
||||
}
|
||||
|
||||
// Filter out documents with no content
|
||||
let docsWithContent = docs.filter(
|
||||
(doc) => doc.pageContent && doc.pageContent.length > 0,
|
||||
);
|
||||
|
||||
// Speed mode logic - simply return first 15 documents with content
|
||||
// No similarity ranking to prioritize speed
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
50,
|
||||
`Ranking sources`,
|
||||
this.searchQuery ? `Search Query: ${this.searchQuery}` : undefined,
|
||||
);
|
||||
|
||||
return docsWithContent.slice(0, 15);
|
||||
} catch (error) {
|
||||
console.error('Error in rerankDocsForSpeed:', error);
|
||||
emitter.emit('error', JSON.stringify({ data: error }));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private processDocs(docs: Document[]) {
|
||||
const fullDocs = docs
|
||||
.map(
|
||||
(_, index) =>
|
||||
`<${index + 1}>\n
|
||||
<title>${docs[index].metadata.title}</title>\n
|
||||
${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + docs[index].metadata.url + '</url>\n'}
|
||||
<content>\n${docs[index].pageContent}\n</content>\n
|
||||
</${index + 1}>\n`,
|
||||
)
|
||||
.join('\n');
|
||||
console.log('Processed docs:', fullDocs);
|
||||
return fullDocs;
|
||||
}
|
||||
|
||||
private async handleStream(
|
||||
stream: AsyncGenerator<StreamEvent, any, any>,
|
||||
emitter: eventEmitter,
|
||||
llm: BaseChatModel,
|
||||
signal: AbortSignal,
|
||||
) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
for await (const event of stream) {
|
||||
if (signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
event.event === 'on_chain_end' &&
|
||||
event.name === 'FinalSourceRetriever'
|
||||
) {
|
||||
const sourcesData = event.data.output;
|
||||
if (this.searchQuery) {
|
||||
emitter.emit(
|
||||
'data',
|
||||
JSON.stringify({
|
||||
type: 'sources',
|
||||
data: sourcesData,
|
||||
searchQuery: this.searchQuery,
|
||||
searchUrl: this.searxngUrl,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
emitter.emit(
|
||||
'data',
|
||||
JSON.stringify({ type: 'sources', data: sourcesData }),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
event.event === 'on_chain_stream' &&
|
||||
event.name === 'FinalResponseGenerator'
|
||||
) {
|
||||
emitter.emit(
|
||||
'data',
|
||||
JSON.stringify({ type: 'response', data: event.data.chunk }),
|
||||
);
|
||||
}
|
||||
if (
|
||||
event.event === 'on_chain_end' &&
|
||||
event.name === 'FinalResponseGenerator'
|
||||
) {
|
||||
const modelName = getModelName(llm);
|
||||
|
||||
// Send model info before ending
|
||||
emitter.emit(
|
||||
'stats',
|
||||
JSON.stringify({
|
||||
type: 'modelStats',
|
||||
data: {
|
||||
modelName,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
emitter.emit('end');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async searchAndAnswer(
|
||||
message: string,
|
||||
history: BaseMessage[],
|
||||
llm: BaseChatModel,
|
||||
embeddings: Embeddings,
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
personaInstructions?: string,
|
||||
focusMode?: string,
|
||||
) {
|
||||
const emitter = new eventEmitter();
|
||||
|
||||
const answeringChain = await this.createAnsweringChain(
|
||||
llm,
|
||||
embeddings,
|
||||
systemInstructions,
|
||||
signal,
|
||||
emitter,
|
||||
personaInstructions,
|
||||
);
|
||||
|
||||
const stream = answeringChain.streamEvents(
|
||||
{
|
||||
chat_history: history,
|
||||
query: message,
|
||||
},
|
||||
{
|
||||
version: 'v1',
|
||||
// Pass the abort signal to the LLM streaming chain
|
||||
signal,
|
||||
},
|
||||
);
|
||||
|
||||
this.handleStream(stream, emitter, llm, signal);
|
||||
|
||||
return emitter;
|
||||
}
|
||||
}
|
||||
|
||||
export default SpeedSearchAgent;
|
||||
|
|
@ -17,7 +17,9 @@ export interface FileData {
|
|||
* @param fileIds Array of file IDs to process
|
||||
* @returns Array of Document objects with content and embeddings
|
||||
*/
|
||||
export async function processFilesToDocuments(fileIds: string[]): Promise<Document[]> {
|
||||
export async function processFilesToDocuments(
|
||||
fileIds: string[],
|
||||
): Promise<Document[]> {
|
||||
if (fileIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
|
@ -91,7 +93,7 @@ export function getRankedDocs(
|
|||
}
|
||||
|
||||
// Import computeSimilarity utility
|
||||
|
||||
|
||||
const similarity = documents.map((doc, i) => {
|
||||
const sim = computeSimilarity(
|
||||
queryEmbedding,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue