feat(app): Introduce quality mode. Improve functionality of balanced mode using readability to get page content and pull relevant excerpts
feat(UI): Show progress during inferrence feat(security): Don't show API keys in the UI any more feat(models): Support Claude 4 Anthropic models
This commit is contained in:
parent
288120dc1d
commit
c47a630372
17 changed files with 2142 additions and 818 deletions
|
|
@ -18,10 +18,7 @@ import { ChatOpenAI } from '@langchain/openai';
|
|||
import crypto from 'crypto';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import { EventEmitter } from 'stream';
|
||||
import {
|
||||
registerCancelToken,
|
||||
cleanupCancelToken,
|
||||
} from '@/lib/cancel-tokens';
|
||||
import { registerCancelToken, cleanupCancelToken } from '@/lib/cancel-tokens';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -115,6 +112,21 @@ const handleEmitterEvents = async (
|
|||
modelName: '',
|
||||
};
|
||||
|
||||
stream.on('progress', (data) => {
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData.type === 'progress') {
|
||||
writer.write(
|
||||
encoder.encode(
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: parsedData.data,
|
||||
messageId: aiMessageId,
|
||||
}) + '\n',
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on('stats', (data) => {
|
||||
const parsedData = JSON.parse(data);
|
||||
if (parsedData.type === 'modelStats') {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export const GET = async (req: Request) => {
|
|||
|
||||
// Helper function to obfuscate API keys
|
||||
const protectApiKey = (key: string | null | undefined) => {
|
||||
return key ? "protected" : key;
|
||||
return key ? 'protected' : key;
|
||||
};
|
||||
|
||||
// Obfuscate all API keys in the response
|
||||
|
|
@ -85,39 +85,57 @@ export const POST = async (req: Request) => {
|
|||
try {
|
||||
const config = await req.json();
|
||||
|
||||
const getUpdatedProtectedValue = (newValue: string, currentConfig: string) => {
|
||||
const getUpdatedProtectedValue = (
|
||||
newValue: string,
|
||||
currentConfig: string,
|
||||
) => {
|
||||
if (newValue === 'protected') {
|
||||
return currentConfig;
|
||||
}
|
||||
return newValue;
|
||||
}
|
||||
};
|
||||
|
||||
const updatedConfig = {
|
||||
MODELS: {
|
||||
OPENAI: {
|
||||
API_KEY: getUpdatedProtectedValue(config.openaiApiKey, getOpenaiApiKey()),
|
||||
API_KEY: getUpdatedProtectedValue(
|
||||
config.openaiApiKey,
|
||||
getOpenaiApiKey(),
|
||||
),
|
||||
},
|
||||
GROQ: {
|
||||
API_KEY: getUpdatedProtectedValue(config.groqApiKey, getGroqApiKey()),
|
||||
},
|
||||
ANTHROPIC: {
|
||||
API_KEY: getUpdatedProtectedValue(config.anthropicApiKey, getAnthropicApiKey()),
|
||||
API_KEY: getUpdatedProtectedValue(
|
||||
config.anthropicApiKey,
|
||||
getAnthropicApiKey(),
|
||||
),
|
||||
},
|
||||
GEMINI: {
|
||||
API_KEY: getUpdatedProtectedValue(config.geminiApiKey, getGeminiApiKey()),
|
||||
API_KEY: getUpdatedProtectedValue(
|
||||
config.geminiApiKey,
|
||||
getGeminiApiKey(),
|
||||
),
|
||||
},
|
||||
OLLAMA: {
|
||||
API_URL: config.ollamaApiUrl,
|
||||
},
|
||||
DEEPSEEK: {
|
||||
API_KEY: getUpdatedProtectedValue(config.deepseekApiKey, getDeepseekApiKey()),
|
||||
API_KEY: getUpdatedProtectedValue(
|
||||
config.deepseekApiKey,
|
||||
getDeepseekApiKey(),
|
||||
),
|
||||
},
|
||||
LM_STUDIO: {
|
||||
API_URL: config.lmStudioApiUrl,
|
||||
},
|
||||
CUSTOM_OPENAI: {
|
||||
API_URL: config.customOpenaiApiUrl,
|
||||
API_KEY: getUpdatedProtectedValue(config.customOpenaiApiKey, getCustomOpenaiApiKey()),
|
||||
API_KEY: getUpdatedProtectedValue(
|
||||
config.customOpenaiApiKey,
|
||||
getCustomOpenaiApiKey(),
|
||||
),
|
||||
MODEL_NAME: config.customOpenaiModelName,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
'use client';
|
||||
|
||||
import { Settings as SettingsIcon, ArrowLeft, Loader2, Info } from 'lucide-react';
|
||||
import {
|
||||
Settings as SettingsIcon,
|
||||
ArrowLeft,
|
||||
Loader2,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Switch } from '@headlessui/react';
|
||||
|
|
@ -128,7 +133,10 @@ const SettingsSection = ({
|
|||
<h2 className="text-black/90 dark:text-white/90 font-medium">{title}</h2>
|
||||
{tooltip && (
|
||||
<div className="relative group">
|
||||
<Info size={16} className="text-black/70 dark:text-white/70 cursor-help" />
|
||||
<Info
|
||||
size={16}
|
||||
className="text-black/70 dark:text-white/70 cursor-help"
|
||||
/>
|
||||
<div className="absolute left-1/2 -translate-x-1/2 bottom-full mb-2 px-3 py-2 bg-black/90 dark:bg-white/90 text-white dark:text-black text-xs rounded-lg opacity-0 group-hover:opacity-100 whitespace-nowrap transition-opacity">
|
||||
{tooltip}
|
||||
</div>
|
||||
|
|
@ -238,7 +246,7 @@ const Page = () => {
|
|||
fetchConfig();
|
||||
}, []);
|
||||
|
||||
const saveConfig = async (key: string, value: any) => {
|
||||
const saveConfig = async (key: string, value: any) => {
|
||||
setSavingStates((prev) => ({ ...prev, [key]: true }));
|
||||
|
||||
try {
|
||||
|
|
@ -798,8 +806,8 @@ const Page = () => {
|
|||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection
|
||||
title="API Keys"
|
||||
<SettingsSection
|
||||
title="API Keys"
|
||||
tooltip="API Key values can be viewed in the config.toml file"
|
||||
>
|
||||
<div className="flex flex-col space-y-4">
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const Chat = ({
|
|||
focusMode,
|
||||
setFocusMode,
|
||||
handleEditMessage,
|
||||
analysisProgress,
|
||||
}: {
|
||||
messages: Message[];
|
||||
sendMessage: (
|
||||
|
|
@ -43,6 +44,11 @@ const Chat = ({
|
|||
focusMode: string;
|
||||
setFocusMode: (mode: string) => void;
|
||||
handleEditMessage: (messageId: string, content: string) => void;
|
||||
analysisProgress: {
|
||||
message: string;
|
||||
current: number;
|
||||
total: number;
|
||||
} | null;
|
||||
}) => {
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false);
|
||||
|
|
@ -220,7 +226,7 @@ const Chat = ({
|
|||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{loading && <MessageBoxLoading />}
|
||||
{loading && <MessageBoxLoading progress={analysisProgress} />}
|
||||
<div className="fixed bottom-24 lg:bottom-10 z-40" style={inputStyle}>
|
||||
{/* Scroll to bottom button - appears above the MessageInput when user has scrolled up */}
|
||||
{manuallyScrolledUp && !isAtBottom && (
|
||||
|
|
|
|||
|
|
@ -29,6 +29,11 @@ export type Message = {
|
|||
modelStats?: ModelStats;
|
||||
searchQuery?: string;
|
||||
searchUrl?: string;
|
||||
progress?: {
|
||||
message: string;
|
||||
current: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export interface File {
|
||||
|
|
@ -270,6 +275,11 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
|||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [scrollTrigger, setScrollTrigger] = useState(0);
|
||||
const [analysisProgress, setAnalysisProgress] = useState<{
|
||||
message: string;
|
||||
current: number;
|
||||
total: number;
|
||||
} | null>(null);
|
||||
|
||||
const [chatHistory, setChatHistory] = useState<[string, string][]>([]);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
|
@ -405,6 +415,11 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'progress') {
|
||||
setAnalysisProgress(data.data);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === 'sources') {
|
||||
sources = data.data;
|
||||
if (!added) {
|
||||
|
|
@ -460,6 +475,9 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
|||
}
|
||||
|
||||
if (data.type === 'messageEnd') {
|
||||
// Clear analysis progress
|
||||
setAnalysisProgress(null);
|
||||
|
||||
setChatHistory((prevHistory) => [
|
||||
...prevHistory,
|
||||
['human', message],
|
||||
|
|
@ -656,6 +674,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
|
|||
focusMode={focusMode}
|
||||
setFocusMode={setFocusMode}
|
||||
handleEditMessage={handleEditMessage}
|
||||
analysisProgress={analysisProgress}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -1,9 +1,37 @@
|
|||
const MessageBoxLoading = () => {
|
||||
interface MessageBoxLoadingProps {
|
||||
progress: {
|
||||
message: string;
|
||||
current: number;
|
||||
total: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const MessageBoxLoading = ({ progress }: MessageBoxLoadingProps) => {
|
||||
return (
|
||||
<div className="flex flex-col space-y-2 w-full lg:w-9/12 bg-light-primary dark:bg-dark-primary animate-pulse rounded-lg py-3">
|
||||
<div className="h-2 rounded-full w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="h-2 rounded-full w-9/12 bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="h-2 rounded-full w-10/12 bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="flex flex-col space-y-4 w-full lg:w-9/12">
|
||||
{progress && progress.current !== progress.total ? (
|
||||
<div className="bg-light-primary dark:bg-dark-primary rounded-lg p-4">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<p className="text-sm text-black/70 dark:text-white/70">
|
||||
{progress.message}
|
||||
</p>
|
||||
<div className="w-full bg-light-secondary dark:bg-dark-secondary rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#24A0ED] transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-light-primary dark:bg-dark-primary animate-pulse rounded-lg py-3">
|
||||
<div className="h-2 rounded-full w-full bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="h-2 mt-2 rounded-full w-9/12 bg-light-secondary dark:bg-dark-secondary" />
|
||||
<div className="h-2 mt-2 rounded-full w-10/12 bg-light-secondary dark:bg-dark-secondary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ const OptimizationModes = [
|
|||
},
|
||||
{
|
||||
key: 'quality',
|
||||
title: 'Quality (Soon)',
|
||||
title: 'Quality',
|
||||
description: 'Get the most thorough and accurate answer',
|
||||
icon: (
|
||||
<Star
|
||||
|
|
@ -80,13 +80,11 @@ const Optimization = ({
|
|||
<PopoverButton
|
||||
onClick={() => handleOptimizationChange(mode.key)}
|
||||
key={i}
|
||||
disabled={mode.key === 'quality'}
|
||||
className={cn(
|
||||
'p-2 rounded-lg flex flex-col items-start justify-start text-start space-y-1 duration-200 cursor-pointer transition',
|
||||
optimizationMode === mode.key
|
||||
? 'bg-light-secondary dark:bg-dark-secondary'
|
||||
: 'hover:bg-light-secondary dark:hover:bg-dark-secondary',
|
||||
mode.key === 'quality' && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row items-center space-x-1 text-black dark:text-white">
|
||||
|
|
|
|||
|
|
@ -3,7 +3,8 @@ export const webSearchRetrieverPrompt = `
|
|||
- You are an AI question rephraser
|
||||
- You will be given a conversation and a user question
|
||||
- Rephrase the question so it is appropriate for web search
|
||||
- Only add additional information or change the meaning of the question if it is necessary for clarity or relevance to the conversation
|
||||
- Only add additional information or change the meaning of the question if it is necessary for clarity or relevance to the conversation such as adding a date or time for current events, or using historical content to augment the question with relevant context
|
||||
- Do not make up any new information like links or URLs
|
||||
- Condense the question to its essence and remove any unnecessary details
|
||||
- Ensure the question is grammatically correct and free of spelling errors
|
||||
- If it is a simple writing task or a greeting (unless the greeting contains a question after it) like Hi, Hello, How are you, etc. instead of a question then you need to return \`not_needed\` as the response in the <answer> XML block
|
||||
|
|
|
|||
|
|
@ -9,6 +9,14 @@ export const PROVIDER_INFO = {
|
|||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
const anthropicChatModels: Record<string, string>[] = [
|
||||
{
|
||||
displayName: 'Claude 4 Opus',
|
||||
key: 'claude-opus-4-20250514',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 4 Sonnet',
|
||||
key: 'claude-sonnet-4-20250514',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3.7 Sonnet',
|
||||
key: 'claude-3-7-sonnet-20250219',
|
||||
|
|
@ -29,10 +37,6 @@ const anthropicChatModels: Record<string, string>[] = [
|
|||
displayName: 'Claude 3 Opus',
|
||||
key: 'claude-3-opus-20240229',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3 Sonnet',
|
||||
key: 'claude-3-sonnet-20240229',
|
||||
},
|
||||
{
|
||||
displayName: 'Claude 3 Haiku',
|
||||
key: 'claude-3-haiku-20240307',
|
||||
|
|
|
|||
|
|
@ -64,6 +64,6 @@ export const searchHandlers: Record<string, MetaSearchAgent> = {
|
|||
rerankThreshold: 0.3,
|
||||
searchWeb: true,
|
||||
summarizer: false,
|
||||
additionalSearchCriteria: '\'site:reddit.com\'',
|
||||
additionalSearchCriteria: "'site:reddit.com'",
|
||||
}),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -22,8 +22,9 @@ import LineOutputParser from '../outputParsers/lineOutputParser';
|
|||
import LineListOutputParser from '../outputParsers/listLineOutputParser';
|
||||
import { searchSearxng } from '../searxng';
|
||||
import computeSimilarity from '../utils/computeSimilarity';
|
||||
import { getDocumentsFromLinks } from '../utils/documents';
|
||||
import { getDocumentsFromLinks, getWebContent } from '../utils/documents';
|
||||
import formatChatHistoryAsString from '../utils/formatHistory';
|
||||
import { getModelName } from '../utils/modelUtils';
|
||||
|
||||
export interface MetaSearchAgentType {
|
||||
searchAndAnswer: (
|
||||
|
|
@ -64,9 +65,35 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
this.config = config;
|
||||
}
|
||||
|
||||
private async createSearchRetrieverChain(llm: BaseChatModel) {
|
||||
/**
|
||||
* Emit a progress event with the given percentage and message
|
||||
*/
|
||||
private emitProgress(
|
||||
emitter: eventEmitter,
|
||||
percentage: number,
|
||||
message: string,
|
||||
) {
|
||||
emitter.emit(
|
||||
'progress',
|
||||
JSON.stringify({
|
||||
type: 'progress',
|
||||
data: {
|
||||
message,
|
||||
current: percentage,
|
||||
total: 100,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async createSearchRetrieverChain(
|
||||
llm: BaseChatModel,
|
||||
emitter: eventEmitter,
|
||||
) {
|
||||
(llm as unknown as ChatOpenAI).temperature = 0;
|
||||
|
||||
this.emitProgress(emitter, 10, `Building search query`);
|
||||
|
||||
return RunnableSequence.from([
|
||||
PromptTemplate.fromTemplate(this.config.queryGeneratorPrompt),
|
||||
llm,
|
||||
|
|
@ -131,6 +158,8 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
}
|
||||
});
|
||||
|
||||
this.emitProgress(emitter, 20, `Summarizing content`);
|
||||
|
||||
await Promise.all(
|
||||
docGroups.map(async (doc) => {
|
||||
const res = await llm.invoke(`
|
||||
|
|
@ -208,6 +237,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
|
||||
return { query: question, docs: docs };
|
||||
} else {
|
||||
this.emitProgress(emitter, 20, `Searching the web`);
|
||||
if (this.config.additionalSearchCriteria) {
|
||||
question = `${question} ${this.config.additionalSearchCriteria}`;
|
||||
}
|
||||
|
|
@ -249,6 +279,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
optimizationMode: 'speed' | 'balanced' | 'quality',
|
||||
systemInstructions: string,
|
||||
signal: AbortSignal,
|
||||
emitter: eventEmitter,
|
||||
) {
|
||||
return RunnableSequence.from([
|
||||
RunnableMap.from({
|
||||
|
|
@ -276,7 +307,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
|
||||
if (this.config.searchWeb) {
|
||||
const searchRetrieverChain =
|
||||
await this.createSearchRetrieverChain(llm);
|
||||
await this.createSearchRetrieverChain(llm, emitter);
|
||||
var date = new Date().toISOString();
|
||||
|
||||
const searchRetrieverResult = await searchRetrieverChain.invoke(
|
||||
|
|
@ -303,8 +334,14 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
fileIds,
|
||||
embeddings,
|
||||
optimizationMode,
|
||||
llm,
|
||||
emitter,
|
||||
signal,
|
||||
);
|
||||
|
||||
console.log('Ranked docs:', sortedDocs);
|
||||
|
||||
this.emitProgress(emitter, 100, `Done`);
|
||||
return sortedDocs;
|
||||
},
|
||||
)
|
||||
|
|
@ -331,11 +368,18 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
fileIds: string[],
|
||||
embeddings: Embeddings,
|
||||
optimizationMode: 'speed' | 'balanced' | 'quality',
|
||||
) {
|
||||
llm: BaseChatModel,
|
||||
emitter: eventEmitter,
|
||||
signal: AbortSignal,
|
||||
): Promise<Document[]> {
|
||||
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);
|
||||
|
|
@ -360,107 +404,216 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
})
|
||||
.flat();
|
||||
|
||||
if (query.toLocaleLowerCase() === 'summarize') {
|
||||
return docs.slice(0, 15);
|
||||
}
|
||||
|
||||
const docsWithContent = docs.filter(
|
||||
let docsWithContent = docs.filter(
|
||||
(doc) => doc.pageContent && doc.pageContent.length > 0,
|
||||
);
|
||||
|
||||
if (optimizationMode === 'speed' || this.config.rerank === false) {
|
||||
if (filesData.length > 0) {
|
||||
const [queryEmbedding] = await Promise.all([
|
||||
embeddings.embedQuery(query),
|
||||
]);
|
||||
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 = filesData.map((fileData, i) => {
|
||||
const sim = computeSimilarity(queryEmbedding, fileData.embeddings);
|
||||
|
||||
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 sortedDocs = similarity
|
||||
.filter(
|
||||
(sim) => sim.similarity > (this.config.rerankThreshold ?? 0.3),
|
||||
)
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.slice(0, 15)
|
||||
.map((sim) => fileDocs[sim.index]);
|
||||
let rankedDocs = similarity
|
||||
.filter((sim) => sim.similarity > (this.config.rerankThreshold ?? 0.3))
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.map((sim) => docsToRank[sim.index]);
|
||||
|
||||
sortedDocs =
|
||||
docsWithContent.length > 0 ? sortedDocs.slice(0, 8) : sortedDocs;
|
||||
rankedDocs =
|
||||
docsToRank.length > 0 ? rankedDocs.slice(0, maxDocs) : rankedDocs;
|
||||
return rankedDocs;
|
||||
};
|
||||
|
||||
if (optimizationMode === 'speed' || this.config.rerank === false) {
|
||||
this.emitProgress(emitter, 50, `Ranking sources`);
|
||||
if (filesData.length > 0) {
|
||||
const sortedFiles = await getRankedDocs(queryEmbedding, true, false, 8);
|
||||
|
||||
return [
|
||||
...sortedDocs,
|
||||
...docsWithContent.slice(0, 15 - sortedDocs.length),
|
||||
...sortedFiles,
|
||||
...docsWithContent.slice(0, 15 - sortedFiles.length),
|
||||
];
|
||||
} else {
|
||||
return docsWithContent.slice(0, 15);
|
||||
}
|
||||
} else if (optimizationMode === 'balanced') {
|
||||
const [docEmbeddings, queryEmbedding] = await Promise.all([
|
||||
embeddings.embedDocuments(
|
||||
docsWithContent.map((doc) => doc.pageContent),
|
||||
),
|
||||
embeddings.embedQuery(query),
|
||||
]);
|
||||
this.emitProgress(emitter, 40, `Ranking sources`);
|
||||
let sortedDocs = await getRankedDocs(queryEmbedding, true, true, 10);
|
||||
|
||||
docsWithContent.push(
|
||||
...filesData.map((fileData) => {
|
||||
return new Document({
|
||||
pageContent: fileData.content,
|
||||
metadata: {
|
||||
title: fileData.fileName,
|
||||
url: `File`,
|
||||
},
|
||||
this.emitProgress(emitter, 60, `Enriching sources`);
|
||||
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;
|
||||
}),
|
||||
);
|
||||
|
||||
docEmbeddings.push(...filesData.map((fileData) => fileData.embeddings));
|
||||
return sortedDocs;
|
||||
} else if (optimizationMode === 'quality') {
|
||||
this.emitProgress(emitter, 30, 'Ranking sources...');
|
||||
|
||||
const similarity = docEmbeddings.map((docEmbedding, i) => {
|
||||
const sim = computeSimilarity(queryEmbedding, docEmbedding);
|
||||
// Get the top ranked web results for detailed analysis based off their preview embeddings
|
||||
const topWebResults = await getRankedDocs(
|
||||
queryEmbedding,
|
||||
false,
|
||||
true,
|
||||
30,
|
||||
);
|
||||
|
||||
return {
|
||||
index: i,
|
||||
similarity: sim,
|
||||
};
|
||||
const summaryParser = new LineOutputParser({
|
||||
key: 'summary',
|
||||
});
|
||||
|
||||
const sortedDocs = similarity
|
||||
.filter((sim) => sim.similarity > (this.config.rerankThreshold ?? 0.3))
|
||||
.sort((a, b) => b.similarity - a.similarity)
|
||||
.slice(0, 15)
|
||||
.map((sim) => docsWithContent[sim.index]);
|
||||
// Get full content and generate detailed summaries for top results sequentially
|
||||
const enhancedDocs: Document[] = [];
|
||||
const maxEnhancedDocs = 5;
|
||||
for (let i = 0; i < topWebResults.length; i++) {
|
||||
if (signal.aborted) {
|
||||
return [];
|
||||
}
|
||||
if (enhancedDocs.length >= maxEnhancedDocs) {
|
||||
break; // Limit to 5 documents
|
||||
}
|
||||
const result = topWebResults[i];
|
||||
|
||||
return sortedDocs;
|
||||
this.emitProgress(
|
||||
emitter,
|
||||
enhancedDocs.length * 10 + 40,
|
||||
`Deep analyzing sources: ${enhancedDocs.length + 1}/${maxEnhancedDocs}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const url = result.metadata.url;
|
||||
const webContent = await getWebContent(url, true);
|
||||
|
||||
if (webContent) {
|
||||
// Generate a detailed summary using the LLM
|
||||
const summary = await llm.invoke(`
|
||||
You are a web content summarizer, tasked with creating a detailed, accurate summary of content from a webpage
|
||||
Your summary should:
|
||||
- Be thorough and comprehensive, capturing all key points
|
||||
- Format the content using markdown, including headings, lists, and tables
|
||||
- Include specific details, numbers, and quotes when relevant
|
||||
- Be concise and to the point, avoiding unnecessary fluff
|
||||
- Answer the user's query, which is: ${query}
|
||||
- Output your answer in an XML format, with the summary inside the \`summary\` XML tag
|
||||
- If the content is not relevant to the query, respond with "not_needed" to start the summary tag, followed by a one line description of why the source is not needed
|
||||
- E.g. "not_needed: There is relevant information in the source, but it doesn't contain specifics about X"
|
||||
- Make sure the reason the source is not needed is very specific and detailed
|
||||
- Include useful links to external resources, if applicable
|
||||
|
||||
Here is the content to summarize:
|
||||
${webContent.metadata.html ? webContent.metadata.html : webContent.pageContent}
|
||||
`);
|
||||
|
||||
const summarizedContent = await summaryParser.parse(
|
||||
summary.content as string,
|
||||
);
|
||||
|
||||
if (
|
||||
summarizedContent.toLocaleLowerCase().startsWith('not_needed')
|
||||
) {
|
||||
console.log(
|
||||
`LLM response for URL "${url}" indicates it's not needed:`,
|
||||
summarizedContent,
|
||||
);
|
||||
continue; // Skip this document if not needed
|
||||
}
|
||||
|
||||
//console.log(`LLM response for URL "${url}":`, summarizedContent);
|
||||
enhancedDocs.push(
|
||||
new Document({
|
||||
pageContent: summarizedContent,
|
||||
metadata: {
|
||||
...webContent.metadata,
|
||||
url: url,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error processing URL ${result.metadata.url}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Add relevant file documents
|
||||
const fileDocs = await getRankedDocs(queryEmbedding, true, false, 5);
|
||||
|
||||
return [...enhancedDocs, ...fileDocs];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
private processDocs(docs: Document[]) {
|
||||
return docs
|
||||
const fullDocs = docs
|
||||
.map(
|
||||
(_, index) =>
|
||||
`${index + 1}. ${docs[index].metadata.title} ${docs[index].pageContent}`,
|
||||
`<${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(
|
||||
|
|
@ -513,38 +666,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
event.event === 'on_chain_end' &&
|
||||
event.name === 'FinalResponseGenerator'
|
||||
) {
|
||||
// Get model name safely with better detection
|
||||
let modelName = 'Unknown';
|
||||
try {
|
||||
// @ts-ignore - Different LLM implementations have different properties
|
||||
if (llm.modelName) {
|
||||
// @ts-ignore
|
||||
modelName = llm.modelName;
|
||||
// @ts-ignore
|
||||
} else if (llm._llm && llm._llm.modelName) {
|
||||
// @ts-ignore
|
||||
modelName = llm._llm.modelName;
|
||||
// @ts-ignore
|
||||
} else if (llm.model && llm.model.modelName) {
|
||||
// @ts-ignore
|
||||
modelName = llm.model.modelName;
|
||||
} else if ('model' in llm) {
|
||||
// @ts-ignore
|
||||
const model = llm.model;
|
||||
if (typeof model === 'string') {
|
||||
modelName = model;
|
||||
// @ts-ignore
|
||||
} else if (model && model.modelName) {
|
||||
// @ts-ignore
|
||||
modelName = model.modelName;
|
||||
}
|
||||
} else if (llm.constructor && llm.constructor.name) {
|
||||
// Last resort: use the class name
|
||||
modelName = llm.constructor.name;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to get model name:', e);
|
||||
}
|
||||
const modelName = getModelName(llm);
|
||||
|
||||
// Send model info before ending
|
||||
emitter.emit(
|
||||
|
|
@ -581,6 +703,7 @@ class MetaSearchAgent implements MetaSearchAgentType {
|
|||
optimizationMode,
|
||||
systemInstructions,
|
||||
signal,
|
||||
emitter,
|
||||
);
|
||||
|
||||
const stream = answeringChain.streamEvents(
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ import { htmlToText } from 'html-to-text';
|
|||
import { RecursiveCharacterTextSplitter } from 'langchain/text_splitter';
|
||||
import { Document } from '@langchain/core/documents';
|
||||
import pdfParse from 'pdf-parse';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { Readability } from '@mozilla/readability';
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
export const getDocumentsFromLinks = async ({ links }: { links: string[] }) => {
|
||||
const splitter = new RecursiveCharacterTextSplitter();
|
||||
|
|
@ -97,3 +100,55 @@ export const getDocumentsFromLinks = async ({ links }: { links: string[] }) => {
|
|||
|
||||
return docs;
|
||||
};
|
||||
|
||||
export const getWebContent = async (
|
||||
url: string,
|
||||
getHtml: boolean = false,
|
||||
): Promise<Document | null> => {
|
||||
try {
|
||||
const response = await fetch(url, { timeout: 5000 });
|
||||
const html = await response.text();
|
||||
|
||||
// Create a DOM from the fetched HTML
|
||||
const dom = new JSDOM(html, { url });
|
||||
|
||||
// Get title before we modify the DOM
|
||||
const originalTitle = dom.window.document.title;
|
||||
|
||||
// Use Readability to parse the article content
|
||||
const reader = new Readability(dom.window.document, { charThreshold: 25 });
|
||||
const article = reader.parse();
|
||||
|
||||
if (!article) {
|
||||
console.warn(`Failed to parse article content for URL: ${url}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize the text content by removing extra spaces and newlines. Iterate through the lines one by one and throw out the ones that are empty or contain only whitespace.
|
||||
const normalizedText =
|
||||
article?.textContent
|
||||
?.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join('\n') || '';
|
||||
|
||||
// Create a Document with the parsed content
|
||||
return new Document({
|
||||
pageContent: normalizedText || '',
|
||||
metadata: {
|
||||
html: getHtml ? article.content : undefined,
|
||||
title: article.title || originalTitle,
|
||||
url: url,
|
||||
excerpt: article.excerpt || undefined,
|
||||
byline: article.byline || undefined,
|
||||
siteName: article.siteName || undefined,
|
||||
readingTime: article.length
|
||||
? Math.ceil(article.length / 1000)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`Error fetching/parsing URL ${url}:`); //, error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
52
src/lib/utils/modelUtils.ts
Normal file
52
src/lib/utils/modelUtils.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
|
||||
/**
|
||||
* Extract the model name from an LLM instance
|
||||
* Handles different LLM implementations that may store the model name in different properties
|
||||
* @param llm The LLM instance
|
||||
* @returns The model name or 'Unknown' if not found
|
||||
*/
|
||||
export function getModelName(llm: BaseChatModel): string {
|
||||
try {
|
||||
// @ts-ignore - Different LLM implementations have different properties
|
||||
if (llm.modelName) {
|
||||
// @ts-ignore
|
||||
return llm.modelName;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (llm._llm && llm._llm.modelName) {
|
||||
// @ts-ignore
|
||||
return llm._llm.modelName;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
if (llm.model && llm.model.modelName) {
|
||||
// @ts-ignore
|
||||
return llm.model.modelName;
|
||||
}
|
||||
|
||||
if ('model' in llm) {
|
||||
// @ts-ignore
|
||||
const model = llm.model;
|
||||
if (typeof model === 'string') {
|
||||
return model;
|
||||
}
|
||||
// @ts-ignore
|
||||
if (model && model.modelName) {
|
||||
// @ts-ignore
|
||||
return model.modelName;
|
||||
}
|
||||
}
|
||||
|
||||
if (llm.constructor && llm.constructor.name) {
|
||||
// Last resort: use the class name
|
||||
return llm.constructor.name;
|
||||
}
|
||||
|
||||
return 'Unknown';
|
||||
} catch (e) {
|
||||
console.error('Failed to get model name:', e);
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue