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:
Willie Zutz 2025-06-28 17:59:12 -06:00
parent de3d26fb15
commit d66300e78e
19 changed files with 832 additions and 996 deletions

View file

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

View file

@ -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) => {

View file

@ -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">

View file

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

View file

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

View file

@ -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]" />,
},
// {

View file

@ -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}`,

View file

@ -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,
},
},
});

View file

@ -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({

View file

@ -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,

View file

@ -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();

View file

@ -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: [],

View file

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

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

View file

@ -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,