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

@ -15,6 +15,7 @@ The system works through these main steps:
## Architecture Details ## Architecture Details
### Technology Stack ### Technology Stack
- **Frontend**: React, Next.js, Tailwind CSS - **Frontend**: React, Next.js, Tailwind CSS
- **Backend**: Node.js - **Backend**: Node.js
- **Database**: SQLite with Drizzle ORM - **Database**: SQLite with Drizzle ORM
@ -23,29 +24,31 @@ The system works through these main steps:
- **Content Processing**: Mozilla Readability, Cheerio, Playwright - **Content Processing**: Mozilla Readability, Cheerio, Playwright
### Database (SQLite + Drizzle ORM) ### Database (SQLite + Drizzle ORM)
- Schema: `src/lib/db/schema.ts` - Schema: `src/lib/db/schema.ts`
- Tables: `messages`, `chats`, `systemPrompts` - Tables: `messages`, `chats`, `systemPrompts`
- Configuration: `drizzle.config.ts` - Configuration: `drizzle.config.ts`
- Local file: `data/db.sqlite` - Local file: `data/db.sqlite`
### AI/ML Stack ### AI/ML Stack
- **LLM Providers**: OpenAI, Anthropic, Groq, Ollama, Gemini, DeepSeek, LM Studio - **LLM Providers**: OpenAI, Anthropic, Groq, Ollama, Gemini, DeepSeek, LM Studio
- **Embeddings**: Xenova Transformers, similarity search (cosine/dot product) - **Embeddings**: Xenova Transformers, similarity search (cosine/dot product)
- **Agents**: `webSearchAgent`, `analyzerAgent`, `synthesizerAgent`, `taskManagerAgent` - **Agents**: `webSearchAgent`, `analyzerAgent`, `synthesizerAgent`, `taskManagerAgent`
### External Services ### External Services
- **Search Engine**: SearXNG integration (`src/lib/searxng.ts`) - **Search Engine**: SearXNG integration (`src/lib/searxng.ts`)
- **Configuration**: TOML-based config file - **Configuration**: TOML-based config file
### Data Flow ### Data Flow
1. User query → Task Manager Agent 1. User query → Task Manager Agent
2. Web Search Agent → SearXNG → Content extraction 2. Web Search Agent → SearXNG → Content extraction
3. Analyzer Agent → Content processing + embedding 3. Analyzer Agent → Content processing + embedding
4. Synthesizer Agent → LLM response generation 4. Synthesizer Agent → LLM response generation
5. Response with cited sources 5. Response with cited sources
## Project Structure ## Project Structure
- `/src/app`: Next.js app directory with page components and API routes - `/src/app`: Next.js app directory with page components and API routes
@ -117,22 +120,26 @@ When working on this codebase, you might need to:
## Code Style & Standards ## Code Style & Standards
### TypeScript Configuration ### TypeScript Configuration
- Strict mode enabled - Strict mode enabled
- ES2017 target - ES2017 target
- Path aliases: `@/*``src/*` - Path aliases: `@/*``src/*`
- No test files (testing not implemented) - No test files (testing not implemented)
### Formatting & Linting ### Formatting & Linting
- ESLint: Next.js core web vitals rules - ESLint: Next.js core web vitals rules
- Prettier: Use `npm run format:write` before commits - Prettier: Use `npm run format:write` before commits
- Import style: Use `@/` prefix for internal imports - Import style: Use `@/` prefix for internal imports
### File Organization ### File Organization
- Components: React functional components with TypeScript - Components: React functional components with TypeScript
- API routes: Next.js App Router (`src/app/api/`) - API routes: Next.js App Router (`src/app/api/`)
- Utilities: Grouped by domain (`src/lib/`) - Utilities: Grouped by domain (`src/lib/`)
- Naming: camelCase for functions/variables, PascalCase for components - Naming: camelCase for functions/variables, PascalCase for components
### Error Handling ### Error Handling
- Use try/catch blocks for async operations - Use try/catch blocks for async operations
- Return structured error responses from API routes - Return structured error responses from API routes

View file

@ -60,7 +60,6 @@ The API accepts a JSON object in the request body, where you define the focus mo
- **`optimizationMode`** (string, optional): Specifies the optimization mode to control the balance between performance and quality. Available modes: - **`optimizationMode`** (string, optional): Specifies the optimization mode to control the balance between performance and quality. Available modes:
- `speed`: Prioritize speed and get the quickest possible answer. Minimum effort retrieving web content. - Only uses SearXNG result previews. - `speed`: Prioritize speed and get the quickest possible answer. Minimum effort retrieving web content. - Only uses SearXNG result previews.
- `balanced`: Find the right balance between speed and accuracy. Medium effort retrieving web content. - Uses web scraping technologies to retrieve partial content from full web pages.
- `agent`: Use an agentic workflow to answer complex multi-part questions. This mode requires a model that is trained for tool use. - `agent`: Use an agentic workflow to answer complex multi-part questions. This mode requires a model that is trained for tool use.
- **`query`** (string, required): The search query or question. - **`query`** (string, required): The search query or question.

View file

@ -11,10 +11,8 @@ Perplexica's architecture consists of the following key components:
- In Agent mode, the application uses an agentic workflow to answer complex multi-part questions - In Agent mode, the application uses an agentic workflow to answer complex multi-part questions
- The agent can use reasoning steps to provide comprehensive answers to complex questions - The agent can use reasoning steps to provide comprehensive answers to complex questions
- Agent mode is experimental and may consume lots of tokens and take a long time to produce responses - Agent mode is experimental and may consume lots of tokens and take a long time to produce responses
- In Balanced mode, the application retrieves web content using Playwright and Mozilla Readability to extract relevant segments of web content
- Because it only uses segments of web content, it can be less accurate than Agent mode
- In Speed mode, the application only uses the preview content returned by SearXNG - In Speed mode, the application only uses the preview content returned by SearXNG
- This content is provided by the search engines and contains minimal context from the actual web page - This content is provided by the search engines and contains minimal context from the actual web page
- This mode is the least accurate and is often prone to hallucination - This mode prioritizes quick responses over accuracy
For a more detailed explanation of how these components work together, see [WORKING.md](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/WORKING.md). For a more detailed explanation of how these components work together, see [WORKING.md](https://github.com/ItzCrazyKns/Perplexica/tree/master/docs/architecture/WORKING.md).

View file

@ -43,7 +43,7 @@ type EmbeddingModel = {
type Body = { type Body = {
message: Message; message: Message;
optimizationMode: 'speed' | 'balanced' | 'agent'; optimizationMode: 'speed' | 'agent';
focusMode: string; focusMode: string;
history: Array<[string, string]>; history: Array<[string, string]>;
files: Array<string>; files: Array<string>;

View file

@ -30,7 +30,7 @@ interface embeddingModel {
} }
interface ChatRequestBody { interface ChatRequestBody {
optimizationMode: 'speed' | 'balanced' | 'agent'; optimizationMode: 'speed' | 'agent';
focusMode: string; focusMode: string;
chatModel?: chatModel; chatModel?: chatModel;
embeddingModel?: embeddingModel; embeddingModel?: embeddingModel;
@ -52,7 +52,7 @@ export const POST = async (req: Request) => {
} }
body.history = body.history || []; body.history = body.history || [];
body.optimizationMode = body.optimizationMode || 'balanced'; body.optimizationMode = body.optimizationMode || 'speed';
body.stream = body.stream || false; body.stream = body.stream || false;
const history: BaseMessage[] = body.history.map((msg) => { const history: BaseMessage[] = body.history.map((msg) => {

View file

@ -158,6 +158,7 @@ const MessageInput = ({
setFileIds={setFileIds} setFileIds={setFileIds}
files={files} files={files}
setFiles={setFiles} setFiles={setFiles}
optimizationMode={optimizationMode}
/> />
</div> </div>
<div className="flex flex-row items-center space-x-2"> <div className="flex flex-row items-center space-x-2">

View file

@ -14,16 +14,23 @@ const Attach = ({
setFileIds, setFileIds,
files, files,
setFiles, setFiles,
optimizationMode,
}: { }: {
fileIds: string[]; fileIds: string[];
setFileIds: (fileIds: string[]) => void; setFileIds: (fileIds: string[]) => void;
files: FileType[]; files: FileType[];
setFiles: (files: FileType[]) => void; setFiles: (files: FileType[]) => void;
optimizationMode: string;
}) => { }) => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fileInputRef = useRef<any>(); const fileInputRef = useRef<any>();
const isSpeedMode = optimizationMode === 'speed';
const isDisabled = isSpeedMode;
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
if (isDisabled) return;
setLoading(true); setLoading(true);
const data = new FormData(); const data = new FormData();
@ -37,7 +44,8 @@ const Attach = ({
const embeddingModel = localStorage.getItem('embeddingModel'); const embeddingModel = localStorage.getItem('embeddingModel');
const chatModelProvider = localStorage.getItem('chatModelProvider'); const chatModelProvider = localStorage.getItem('chatModelProvider');
const chatModel = localStorage.getItem('chatModel'); 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_provider', embeddingModelProvider!);
data.append('embedding_model', embeddingModel!); data.append('embedding_model', embeddingModel!);
@ -67,122 +75,166 @@ const Attach = ({
</p> </p>
</div> </div>
) : files.length > 0 ? ( ) : files.length > 0 ? (
<Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg"> <div className="relative group">
<PopoverButton <Popover className="relative w-full max-w-[15rem] md:max-w-md lg:max-w-lg">
type="button" <PopoverButton
className={cn( type="button"
'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', disabled={isDisabled}
files.length > 0 ? '-ml-2 lg:-ml-3' : '', 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' : '',
{files.length > 1 && ( isDisabled
<> ? 'text-black/20 dark:text-white/20 cursor-not-allowed'
<File size={19} className="text-sky-400" /> : 'text-black/50 dark:text-white/50 hover:bg-light-secondary dark:hover:bg-dark-secondary hover:text-black dark:hover:text-white',
<p className="text-sky-400 inline whitespace-nowrap text-xs font-medium"> )}
{files.length} files >
</p> {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 && ( {files.length === 1 && (
<> <>
<File size={18} className="text-sky-400" /> <File size={18} className={isDisabled ? 'text-sky-900' : 'text-sky-400'} />
<p className="text-sky-400 text-xs font-medium"> <p className={cn("text-xs font-medium", isDisabled ? 'text-sky-900' : 'text-sky-400')}>
{files[0].fileName.length > 10 {files[0].fileName.length > 10
? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) + ? files[0].fileName.replace(/\.\w+$/, '').substring(0, 3) +
'...' + '...' +
files[0].fileExtension files[0].fileExtension
: files[0].fileName} : files[0].fileName}
</p> </p>
</> </>
)} )}
</PopoverButton> </PopoverButton>
<Transition <Transition
as={Fragment} as={Fragment}
enter="transition ease-out duration-150" enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1" enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0" enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150" leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0" leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1" leaveTo="opacity-0 translate-y-1"
> >
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0"> <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="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"> <div className="flex flex-row items-center justify-between px-3 py-2">
<h4 className="text-black dark:text-white font-medium text-sm"> <h4 className="text-black dark:text-white font-medium text-sm">
Attached files Attached files
</h4> </h4>
<div className="flex flex-row items-center space-x-4"> <div className="flex flex-row items-center space-x-4">
<button <button
type="button" type="button"
onClick={() => fileInputRef.current.click()} onClick={() => !isDisabled && 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" disabled={isDisabled}
> className={cn(
<input 'flex flex-row items-center space-x-1 transition duration-200',
type="file" isDisabled
onChange={handleChange} ? 'text-black/20 dark:text-white/20 cursor-not-allowed'
ref={fileInputRef} : 'text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white',
accept=".pdf,.docx,.txt" )}
multiple >
hidden <input
/> type="file"
<Plus size={18} /> onChange={handleChange}
<p className="text-xs">Add</p> ref={fileInputRef}
</button> accept=".pdf,.docx,.txt"
<button multiple
onClick={() => { hidden
setFiles([]); disabled={isDisabled}
setFileIds([]); />
}} <Plus size={18} />
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" <p className="text-xs">Add</p>
> </button>
<Trash size={14} /> <button
<p className="text-xs">Clear</p> onClick={() => {
</button> 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> </div>
<div className="h-[0.5px] mx-2 bg-white/10" /> </PopoverPanel>
<div className="flex flex-col items-center"> </Transition>
{files.map((file, i) => ( </Popover>
<div {isSpeedMode && (
key={i} <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">
className="flex flex-row items-center justify-start w-full space-x-3 p-3" <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="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md"> <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>
<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>
</PopoverPanel> </div>
</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" <div className="relative group">
onChange={handleChange} <button
ref={fileInputRef} type="button"
accept=".pdf,.docx,.txt" onClick={() => !isDisabled && fileInputRef.current.click()}
multiple disabled={isDisabled}
hidden className={cn(
/> 'flex flex-row items-center space-x-1 rounded-xl transition duration-200 p-2',
<Paperclip size="18" /> isDisabled
</button> ? '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', key: 'speed',
title: 'Speed', title: 'Speed',
description: 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]" />, icon: <Zap size={20} className="text-[#FF9800]" />,
}, },
// { // {

View file

@ -15,9 +15,7 @@ const RouterDecisionSchema = z.object({
decision: z decision: z
.enum(['file_search', 'web_search', 'analyzer']) .enum(['file_search', 'web_search', 'analyzer'])
.describe('The next step to take in the workflow'), .describe('The next step to take in the workflow'),
reasoning: z reasoning: z.string().describe('Explanation of why this decision was made'),
.string()
.describe('Explanation of why this decision was made'),
}); });
type RouterDecision = z.infer<typeof RouterDecisionSchema>; 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 // Extract focus mode from state - this should now come from the API
const focusMode = state.focusMode || 'webSearch'; const focusMode = state.focusMode || 'webSearch';
const hasFiles = state.fileIds && state.fileIds.length > 0; const hasFiles = state.fileIds && state.fileIds.length > 0;
const documentCount = state.relevantDocuments.length; const documentCount = state.relevantDocuments.length;
const searchHistory = state.searchInstructionHistory.join(', ') || 'None'; const searchHistory = state.searchInstructionHistory.join(', ') || 'None';
// Extract file topics if files are available // 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 // Emit routing decision event
this.emitter.emit('agent_action', { this.emitter.emit('agent_action', {
@ -97,9 +97,12 @@ export class ContentRouterAgent {
}); });
// Use structured output for routing decision // Use structured output for routing decision
const structuredLlm = this.llm.withStructuredOutput(RouterDecisionSchema, { const structuredLlm = this.llm.withStructuredOutput(
name: 'route_content', RouterDecisionSchema,
}); {
name: 'route_content',
},
);
const routerDecision = await structuredLlm.invoke( const routerDecision = await structuredLlm.invoke(
[...removeThinkingBlocksFromMessages(state.messages), prompt], [...removeThinkingBlocksFromMessages(state.messages), prompt],
@ -112,7 +115,11 @@ export class ContentRouterAgent {
console.log(`Focus mode: ${focusMode}`); console.log(`Focus mode: ${focusMode}`);
// Validate decision based on focus mode restrictions // 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 // Emit routing result event
this.emitter.emit('agent_action', { this.emitter.emit('agent_action', {
@ -163,15 +170,15 @@ export class ContentRouterAgent {
*/ */
private async extractFileTopics(fileIds: string[]): Promise<string> { private async extractFileTopics(fileIds: string[]): Promise<string> {
try { try {
const topics = fileIds.map(fileId => { const topics = fileIds.map((fileId) => {
try { try {
const filePath = path.join(process.cwd(), 'uploads', fileId); const filePath = path.join(process.cwd(), 'uploads', fileId);
const contentPath = filePath + '-extracted.json'; const contentPath = filePath + '-extracted.json';
if (fs.existsSync(contentPath)) { if (fs.existsSync(contentPath)) {
const content = JSON.parse(fs.readFileSync(contentPath, 'utf8')); const content = JSON.parse(fs.readFileSync(contentPath, 'utf8'));
const filename = content.title || 'Document'; const filename = content.title || 'Document';
// Use LLM-generated semantic topics if available, otherwise fall back to filename // Use LLM-generated semantic topics if available, otherwise fall back to filename
const semanticTopics = content.topics; const semanticTopics = content.topics;
return semanticTopics || filename; return semanticTopics || filename;
@ -182,7 +189,7 @@ export class ContentRouterAgent {
return 'Unknown Document'; return 'Unknown Document';
} }
}); });
return topics.join('; '); return topics.join('; ');
} catch (error) { } catch (error) {
console.warn('Error extracting file topics:', error); console.warn('Error extracting file topics:', error);
@ -199,16 +206,17 @@ export class ContentRouterAgent {
hasFiles: boolean, hasFiles: boolean,
): RouterDecision { ): RouterDecision {
// Enforce focus mode restrictions for chat and localResearch modes // Enforce focus mode restrictions for chat and localResearch modes
if ((focusMode === 'chat' || focusMode === 'localResearch') && if (
decision.decision === 'web_search') { (focusMode === 'chat' || focusMode === 'localResearch') &&
decision.decision === 'web_search'
) {
// Override to file_search if files are available, otherwise analyzer // Override to file_search if files are available, otherwise analyzer
const fallbackDecision = hasFiles ? 'file_search' : 'analyzer'; const fallbackDecision = hasFiles ? 'file_search' : 'analyzer';
console.log( 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 { return {
decision: fallbackDecision as 'file_search' | 'analyzer', decision: fallbackDecision as 'file_search' | 'analyzer',
reasoning: `Overridden to ${fallbackDecision} - web search not allowed in ${focusMode} mode. ${decision.reasoning}`, 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 { Document } from 'langchain/document';
import { AgentState } from './agentState'; import { AgentState } from './agentState';
import { Embeddings } from '@langchain/core/embeddings'; import { Embeddings } from '@langchain/core/embeddings';
import { processFilesToDocuments, getRankedDocs } from '../utils/fileProcessing'; import {
processFilesToDocuments,
getRankedDocs,
} from '../utils/fileProcessing';
export class FileSearchAgent { export class FileSearchAgent {
private llm: BaseChatModel; private llm: BaseChatModel;
@ -79,12 +82,16 @@ export class FileSearchAgent {
return new Command({ return new Command({
goto: 'analyzer', goto: 'analyzer',
update: { 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 // Emit searching file content event
this.emitter.emit('agent_action', { this.emitter.emit('agent_action', {
@ -139,7 +146,11 @@ export class FileSearchAgent {
return new Command({ return new Command({
goto: 'analyzer', goto: 'analyzer',
update: { 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, totalTasks: state.tasks?.length || 1,
relevantSections: rankedDocuments.length, relevantSections: rankedDocuments.length,
searchedDocuments: fileDocuments.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 { try {
// Format the prompt using the external template // Format the prompt using the external template
const template = PromptTemplate.fromTemplate(synthesizerPrompt); const template = PromptTemplate.fromTemplate(synthesizerPrompt);
const conversationHistory = removeThinkingBlocksFromMessages(state.messages) const conversationHistory =
.map((msg) => `<${msg.getType()}>${msg.content}</${msg.getType()}>`) removeThinkingBlocksFromMessages(state.messages)
.join('\n') || 'No previous conversation context'; .map((msg) => `<${msg.getType()}>${msg.content}</${msg.getType()}>`)
.join('\n') || 'No previous conversation context';
const relevantDocuments = state.relevantDocuments const relevantDocuments = state.relevantDocuments
.map( .map((doc, index) => {
(doc, index) => { const isFile = doc.metadata?.url?.toLowerCase().includes('file');
const isFile = doc.metadata?.url?.toLowerCase().includes('file'); return `<${index + 1}>\n
return `<${index + 1}>\n
<title>${doc.metadata.title}</title>\n <title>${doc.metadata.title}</title>\n
<source_type>${isFile ? 'file' : 'web'}</source_type>\n <source_type>${isFile ? 'file' : 'web'}</source_type>\n
${isFile ? '' : '\n<url>' + doc.metadata.url + '</url>\n'} ${isFile ? '' : '\n<url>' + doc.metadata.url + '</url>\n'}
<content>\n${doc.pageContent}\n</content>\n <content>\n${doc.pageContent}\n</content>\n
</${index + 1}>`; </${index + 1}>`;
} })
)
.join('\n'); .join('\n');
const formattedPrompt = await template.format({ const formattedPrompt = await template.format({

View file

@ -127,12 +127,13 @@ export class TaskManagerAgent {
}); });
const template = PromptTemplate.fromTemplate(taskBreakdownPrompt); const template = PromptTemplate.fromTemplate(taskBreakdownPrompt);
// Create file context information // Create file context information
const fileContext = state.fileIds && state.fileIds.length > 0 const fileContext =
? `Files attached: ${state.fileIds.length} file(s) are available for analysis. Consider creating tasks that can leverage these attached files when appropriate.` state.fileIds && state.fileIds.length > 0
: 'No files attached: Focus on tasks that can be answered through web research or general knowledge.'; ? `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({ const prompt = await template.format({
systemInstructions: this.systemInstructions, systemInstructions: this.systemInstructions,
fileContext: fileContext, fileContext: fileContext,

View file

@ -153,9 +153,9 @@ export class AgentSearch {
* Execute the agent search workflow * Execute the agent search workflow
*/ */
async searchAndAnswer( async searchAndAnswer(
query: string, query: string,
history: BaseMessage[] = [], history: BaseMessage[] = [],
fileIds: string[] = [] fileIds: string[] = [],
) { ) {
const workflow = this.createWorkflow(); const workflow = this.createWorkflow();

View file

@ -1,6 +1,9 @@
import MetaSearchAgent from '@/lib/search/metaSearchAgent'; import MetaSearchAgent from '@/lib/search/metaSearchAgent';
import SpeedSearchAgent from '@/lib/search/speedSearch';
import prompts from '../prompts'; import prompts from '../prompts';
export { default as SpeedSearchAgent } from './speedSearch';
export const searchHandlers: Record<string, MetaSearchAgent> = { export const searchHandlers: Record<string, MetaSearchAgent> = {
webSearch: new MetaSearchAgent({ webSearch: new MetaSearchAgent({
activeEngines: [], activeEngines: [],

View file

@ -1,32 +1,9 @@
import type { Embeddings } from '@langchain/core/embeddings'; import type { Embeddings } from '@langchain/core/embeddings';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'; import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { BaseMessage } from '@langchain/core/messages'; 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 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 { AgentSearch } from './agentSearch';
import SpeedSearchAgent from './speedSearch';
export interface MetaSearchAgentType { export interface MetaSearchAgentType {
searchAndAnswer: ( searchAndAnswer: (
@ -34,7 +11,7 @@ export interface MetaSearchAgentType {
history: BaseMessage[], history: BaseMessage[],
llm: BaseChatModel, llm: BaseChatModel,
embeddings: Embeddings, embeddings: Embeddings,
optimizationMode: 'speed' | 'balanced' | 'agent', optimizationMode: 'speed' | 'agent',
fileIds: string[], fileIds: string[],
systemInstructions: string, systemInstructions: string,
signal: AbortSignal, signal: AbortSignal,
@ -54,623 +31,13 @@ interface Config {
additionalSearchCriteria?: string; additionalSearchCriteria?: string;
} }
type BasicChainInput = {
chat_history: BaseMessage[];
query: string;
};
class MetaSearchAgent implements MetaSearchAgentType { class MetaSearchAgent implements MetaSearchAgentType {
private config: Config; private config: Config;
private strParser = new StringOutputParser();
private searchQuery?: string;
private searxngUrl?: string;
constructor(config: Config) { constructor(config: Config) {
this.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 * Execute agent workflow asynchronously with proper streaming support
*/ */
@ -719,7 +86,7 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + do
history: BaseMessage[], history: BaseMessage[],
llm: BaseChatModel, llm: BaseChatModel,
embeddings: Embeddings, embeddings: Embeddings,
optimizationMode: 'speed' | 'balanced' | 'agent', optimizationMode: 'speed' | 'agent',
fileIds: string[], fileIds: string[],
systemInstructions: string, systemInstructions: string,
signal: AbortSignal, signal: AbortSignal,
@ -728,50 +95,35 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + do
) { ) {
const emitter = new eventEmitter(); const emitter = new eventEmitter();
// Branch to agent search if optimization mode is 'agent' // Branch to speed search if optimization mode is 'speed'
if (optimizationMode === 'agent') { if (optimizationMode === 'speed') {
// Execute agent workflow asynchronously to maintain streaming const speedSearchAgent = new SpeedSearchAgent(this.config);
this.executeAgentWorkflow( return speedSearchAgent.searchAndAnswer(
llm,
embeddings,
emitter,
message, message,
history, history,
fileIds, llm,
embeddings,
systemInstructions, systemInstructions,
personaInstructions || '',
signal, signal,
focusMode || 'webSearch', personaInstructions,
focusMode,
); );
return emitter;
} }
// Existing logic for other optimization modes // Execute agent workflow for 'agent' mode
const answeringChain = await this.createAnsweringChain( this.executeAgentWorkflow(
llm, llm,
fileIds,
embeddings, embeddings,
optimizationMode,
systemInstructions,
signal,
emitter, 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; 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 * @param fileIds Array of file IDs to process
* @returns Array of Document objects with content and embeddings * @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) { if (fileIds.length === 0) {
return []; return [];
} }
@ -91,7 +93,7 @@ export function getRankedDocs(
} }
// Import computeSimilarity utility // Import computeSimilarity utility
const similarity = documents.map((doc, i) => { const similarity = documents.map((doc, i) => {
const sim = computeSimilarity( const sim = computeSimilarity(
queryEmbedding, queryEmbedding,

View file

@ -0,0 +1,3 @@
Who won the 2025 Super Bowl?
Who won the 2023 Formula One Driver's Championship?
Who won the 2022 World Cup?