feat: монорепо миграция, Discover/SearxNG улучшения
- Миграция на монорепозиторий (apps/frontend, apps/chat-service, etc.) - Discover: проверка SearxNG, понятное empty state при ненастроенном поиске - searxng.ts: валидация URL, проверка JSON-ответа, авто-добавление http:// - docker/searxng-config: настройки для JSON API SearxNG Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
66
apps/chat-service/src/lib/agents/media/image.ts
Normal file
66
apps/chat-service/src/lib/agents/media/image.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/* I don't think can be classified as agents but to keep the structure consistent i guess ill keep it here */
|
||||
|
||||
import { searchSearxng } from '@/lib/searxng';
|
||||
import {
|
||||
imageSearchFewShots,
|
||||
imageSearchPrompt,
|
||||
} from '@/lib/prompts/media/image';
|
||||
import BaseLLM from '@/lib/models/base/llm';
|
||||
import z from 'zod';
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
|
||||
type ImageSearchChainInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
type ImageSearchResult = {
|
||||
img_src: string;
|
||||
url: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const searchImages = async (
|
||||
input: ImageSearchChainInput,
|
||||
llm: BaseLLM<any>,
|
||||
) => {
|
||||
const schema = z.object({
|
||||
query: z.string().describe('The image search query.'),
|
||||
});
|
||||
|
||||
const res = await llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: imageSearchPrompt,
|
||||
},
|
||||
...imageSearchFewShots,
|
||||
{
|
||||
role: 'user',
|
||||
content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<follow_up>\n${input.query}\n</follow_up>`,
|
||||
},
|
||||
],
|
||||
schema: schema,
|
||||
});
|
||||
|
||||
const searchRes = await searchSearxng(res.query, {
|
||||
engines: ['bing images', 'google images'],
|
||||
});
|
||||
|
||||
const images: ImageSearchResult[] = [];
|
||||
|
||||
searchRes.results.forEach((result) => {
|
||||
if (result.img_src && result.url && result.title) {
|
||||
images.push({
|
||||
img_src: result.img_src,
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return images.slice(0, 10);
|
||||
};
|
||||
|
||||
export default searchImages;
|
||||
66
apps/chat-service/src/lib/agents/media/video.ts
Normal file
66
apps/chat-service/src/lib/agents/media/video.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
import { searchSearxng } from '@/lib/searxng';
|
||||
import {
|
||||
videoSearchFewShots,
|
||||
videoSearchPrompt,
|
||||
} from '@/lib/prompts/media/videos';
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
import BaseLLM from '@/lib/models/base/llm';
|
||||
import z from 'zod';
|
||||
|
||||
type VideoSearchChainInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
query: string;
|
||||
};
|
||||
|
||||
type VideoSearchResult = {
|
||||
img_src: string;
|
||||
url: string;
|
||||
title: string;
|
||||
iframe_src: string;
|
||||
};
|
||||
|
||||
const searchVideos = async (
|
||||
input: VideoSearchChainInput,
|
||||
llm: BaseLLM<any>,
|
||||
) => {
|
||||
const schema = z.object({
|
||||
query: z.string().describe('The video search query.'),
|
||||
});
|
||||
|
||||
const res = await llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: videoSearchPrompt,
|
||||
},
|
||||
...videoSearchFewShots,
|
||||
{
|
||||
role: 'user',
|
||||
content: `<conversation>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation>\n<follow_up>\n${input.query}\n</follow_up>`,
|
||||
},
|
||||
],
|
||||
schema: schema,
|
||||
});
|
||||
|
||||
const searchRes = await searchSearxng(res.query, {
|
||||
engines: ['youtube'],
|
||||
});
|
||||
|
||||
const videos: VideoSearchResult[] = [];
|
||||
|
||||
searchRes.results.forEach((result) => {
|
||||
if (result.thumbnail && result.url && result.title && result.iframe_src) {
|
||||
videos.push({
|
||||
img_src: result.thumbnail,
|
||||
url: result.url,
|
||||
title: result.title,
|
||||
iframe_src: result.iframe_src,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return videos.slice(0, 10);
|
||||
};
|
||||
|
||||
export default searchVideos;
|
||||
99
apps/chat-service/src/lib/agents/search/api.ts
Normal file
99
apps/chat-service/src/lib/agents/search/api.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { ResearcherOutput, SearchAgentInput } from './types';
|
||||
import SessionManager from '@/lib/session';
|
||||
import { classify } from './classifier';
|
||||
import Researcher from './researcher';
|
||||
import { getWriterPrompt } from '@/lib/prompts/search/writer';
|
||||
import { WidgetExecutor } from './widgets';
|
||||
|
||||
class APISearchAgent {
|
||||
async searchAsync(session: SessionManager, input: SearchAgentInput) {
|
||||
const classification = await classify({
|
||||
chatHistory: input.chatHistory,
|
||||
enabledSources: input.config.sources,
|
||||
query: input.followUp,
|
||||
llm: input.config.llm,
|
||||
});
|
||||
|
||||
const widgetPromise = WidgetExecutor.executeAll({
|
||||
classification,
|
||||
chatHistory: input.chatHistory,
|
||||
followUp: input.followUp,
|
||||
llm: input.config.llm,
|
||||
});
|
||||
|
||||
let searchPromise: Promise<ResearcherOutput> | null = null;
|
||||
|
||||
if (!classification.classification.skipSearch) {
|
||||
const researcher = new Researcher();
|
||||
searchPromise = researcher.research(SessionManager.createSession(), {
|
||||
chatHistory: input.chatHistory,
|
||||
followUp: input.followUp,
|
||||
classification: classification,
|
||||
config: input.config,
|
||||
});
|
||||
}
|
||||
|
||||
const [widgetOutputs, searchResults] = await Promise.all([
|
||||
widgetPromise,
|
||||
searchPromise,
|
||||
]);
|
||||
|
||||
if (searchResults) {
|
||||
session.emit('data', {
|
||||
type: 'searchResults',
|
||||
data: searchResults.searchFindings,
|
||||
});
|
||||
}
|
||||
|
||||
session.emit('data', {
|
||||
type: 'researchComplete',
|
||||
});
|
||||
|
||||
const finalContext =
|
||||
searchResults?.searchFindings
|
||||
.map(
|
||||
(f, index) =>
|
||||
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
|
||||
)
|
||||
.join('\n') || '';
|
||||
|
||||
const widgetContext = widgetOutputs
|
||||
.map((o) => {
|
||||
return `<result>${o.llmContext}</result>`;
|
||||
})
|
||||
.join('\n-------------\n');
|
||||
|
||||
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
|
||||
|
||||
const writerPrompt = getWriterPrompt(
|
||||
finalContextWithWidgets,
|
||||
input.config.systemInstructions,
|
||||
input.config.mode,
|
||||
);
|
||||
|
||||
const answerStream = input.config.llm.streamText({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: writerPrompt,
|
||||
},
|
||||
...input.chatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: input.followUp,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
for await (const chunk of answerStream) {
|
||||
session.emit('data', {
|
||||
type: 'response',
|
||||
data: chunk.contentChunk,
|
||||
});
|
||||
}
|
||||
|
||||
session.emit('end', {});
|
||||
}
|
||||
}
|
||||
|
||||
export default APISearchAgent;
|
||||
53
apps/chat-service/src/lib/agents/search/classifier.ts
Normal file
53
apps/chat-service/src/lib/agents/search/classifier.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import z from 'zod';
|
||||
import { ClassifierInput } from './types';
|
||||
import { classifierPrompt } from '@/lib/prompts/search/classifier';
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
|
||||
const schema = z.object({
|
||||
classification: z.object({
|
||||
skipSearch: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to skip the search step.'),
|
||||
personalSearch: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to perform a personal search.'),
|
||||
academicSearch: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to perform an academic search.'),
|
||||
discussionSearch: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to perform a discussion search.'),
|
||||
showWeatherWidget: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to show the weather widget.'),
|
||||
showStockWidget: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to show the stock widget.'),
|
||||
showCalculationWidget: z
|
||||
.boolean()
|
||||
.describe('Indicates whether to show the calculation widget.'),
|
||||
}),
|
||||
standaloneFollowUp: z
|
||||
.string()
|
||||
.describe(
|
||||
"A self-contained, context-independent reformulation of the user's question.",
|
||||
),
|
||||
});
|
||||
|
||||
export const classify = async (input: ClassifierInput) => {
|
||||
const output = await input.llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: classifierPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_query>\n${input.query}\n</user_query>`,
|
||||
},
|
||||
],
|
||||
schema,
|
||||
});
|
||||
|
||||
return output;
|
||||
};
|
||||
186
apps/chat-service/src/lib/agents/search/index.ts
Normal file
186
apps/chat-service/src/lib/agents/search/index.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { ResearcherOutput, SearchAgentInput } from './types';
|
||||
import SessionManager from '@/lib/session';
|
||||
import { classify } from './classifier';
|
||||
import Researcher from './researcher';
|
||||
import { getWriterPrompt } from '@/lib/prompts/search/writer';
|
||||
import { WidgetExecutor } from './widgets';
|
||||
import db from '@/lib/db';
|
||||
import { chats, messages } from '@/lib/db/schema';
|
||||
import { and, eq, gt } from 'drizzle-orm';
|
||||
import { TextBlock } from '@/lib/types';
|
||||
|
||||
class SearchAgent {
|
||||
async searchAsync(session: SessionManager, input: SearchAgentInput) {
|
||||
const exists = await db.query.messages.findFirst({
|
||||
where: and(
|
||||
eq(messages.chatId, input.chatId),
|
||||
eq(messages.messageId, input.messageId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
await db.insert(messages).values({
|
||||
chatId: input.chatId,
|
||||
messageId: input.messageId,
|
||||
backendId: session.id,
|
||||
query: input.followUp,
|
||||
createdAt: new Date().toISOString(),
|
||||
status: 'answering',
|
||||
responseBlocks: [],
|
||||
});
|
||||
} else {
|
||||
await db
|
||||
.delete(messages)
|
||||
.where(
|
||||
and(eq(messages.chatId, input.chatId), gt(messages.id, exists.id)),
|
||||
)
|
||||
.execute();
|
||||
await db
|
||||
.update(messages)
|
||||
.set({
|
||||
status: 'answering',
|
||||
backendId: session.id,
|
||||
responseBlocks: [],
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(messages.chatId, input.chatId),
|
||||
eq(messages.messageId, input.messageId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
const classification = await classify({
|
||||
chatHistory: input.chatHistory,
|
||||
enabledSources: input.config.sources,
|
||||
query: input.followUp,
|
||||
llm: input.config.llm,
|
||||
});
|
||||
|
||||
const widgetPromise = WidgetExecutor.executeAll({
|
||||
classification,
|
||||
chatHistory: input.chatHistory,
|
||||
followUp: input.followUp,
|
||||
llm: input.config.llm,
|
||||
}).then((widgetOutputs) => {
|
||||
widgetOutputs.forEach((o) => {
|
||||
session.emitBlock({
|
||||
id: crypto.randomUUID(),
|
||||
type: 'widget',
|
||||
data: {
|
||||
widgetType: o.type,
|
||||
params: o.data,
|
||||
},
|
||||
});
|
||||
});
|
||||
return widgetOutputs;
|
||||
});
|
||||
|
||||
let searchPromise: Promise<ResearcherOutput> | null = null;
|
||||
|
||||
if (!classification.classification.skipSearch) {
|
||||
const researcher = new Researcher();
|
||||
searchPromise = researcher.research(session, {
|
||||
chatHistory: input.chatHistory,
|
||||
followUp: input.followUp,
|
||||
classification: classification,
|
||||
config: input.config,
|
||||
});
|
||||
}
|
||||
|
||||
const [widgetOutputs, searchResults] = await Promise.all([
|
||||
widgetPromise,
|
||||
searchPromise,
|
||||
]);
|
||||
|
||||
session.emit('data', {
|
||||
type: 'researchComplete',
|
||||
});
|
||||
|
||||
const finalContext =
|
||||
searchResults?.searchFindings
|
||||
.map(
|
||||
(f, index) =>
|
||||
`<result index=${index + 1} title=${f.metadata.title}>${f.content}</result>`,
|
||||
)
|
||||
.join('\n') || '';
|
||||
|
||||
const widgetContext = widgetOutputs
|
||||
.map((o) => {
|
||||
return `<result>${o.llmContext}</result>`;
|
||||
})
|
||||
.join('\n-------------\n');
|
||||
|
||||
const finalContextWithWidgets = `<search_results note="These are the search results and assistant can cite these">\n${finalContext}\n</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, assistant can use this information to answer the query but do not CITE this as a souce">\n${widgetContext}\n</widgets_result>`;
|
||||
|
||||
const writerPrompt = getWriterPrompt(
|
||||
finalContextWithWidgets,
|
||||
input.config.systemInstructions,
|
||||
input.config.mode,
|
||||
);
|
||||
const answerStream = input.config.llm.streamText({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: writerPrompt,
|
||||
},
|
||||
...input.chatHistory,
|
||||
{
|
||||
role: 'user',
|
||||
content: input.followUp,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
let responseBlockId = '';
|
||||
|
||||
for await (const chunk of answerStream) {
|
||||
if (!responseBlockId) {
|
||||
const block: TextBlock = {
|
||||
id: crypto.randomUUID(),
|
||||
type: 'text',
|
||||
data: chunk.contentChunk,
|
||||
};
|
||||
|
||||
session.emitBlock(block);
|
||||
|
||||
responseBlockId = block.id;
|
||||
} else {
|
||||
const block = session.getBlock(responseBlockId) as TextBlock | null;
|
||||
|
||||
if (!block) {
|
||||
continue;
|
||||
}
|
||||
|
||||
block.data += chunk.contentChunk;
|
||||
|
||||
session.updateBlock(block.id, [
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/data',
|
||||
value: block.data,
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
session.emit('end', {});
|
||||
|
||||
await db
|
||||
.update(messages)
|
||||
.set({
|
||||
status: 'completed',
|
||||
responseBlocks: session.getAllBlocks(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(messages.chatId, input.chatId),
|
||||
eq(messages.messageId, input.messageId),
|
||||
),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
}
|
||||
|
||||
export default SearchAgent;
|
||||
122
apps/chat-service/src/lib/agents/search/types.ts
Normal file
122
apps/chat-service/src/lib/agents/search/types.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import z from 'zod';
|
||||
import BaseLLM from '../../models/base/llm';
|
||||
import BaseEmbedding from '@/lib/models/base/embedding';
|
||||
import SessionManager from '@/lib/session';
|
||||
import { ChatTurnMessage, Chunk } from '@/lib/types';
|
||||
|
||||
export type SearchSources = 'web' | 'discussions' | 'academic';
|
||||
|
||||
export type SearchAgentConfig = {
|
||||
sources: SearchSources[];
|
||||
fileIds: string[];
|
||||
llm: BaseLLM<any>;
|
||||
embedding: BaseEmbedding<any>;
|
||||
mode: 'speed' | 'balanced' | 'quality';
|
||||
systemInstructions: string;
|
||||
};
|
||||
|
||||
export type SearchAgentInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
followUp: string;
|
||||
config: SearchAgentConfig;
|
||||
chatId: string;
|
||||
messageId: string;
|
||||
};
|
||||
|
||||
export type WidgetInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
followUp: string;
|
||||
classification: ClassifierOutput;
|
||||
llm: BaseLLM<any>;
|
||||
};
|
||||
|
||||
export type Widget = {
|
||||
type: string;
|
||||
shouldExecute: (classification: ClassifierOutput) => boolean;
|
||||
execute: (input: WidgetInput) => Promise<WidgetOutput | void>;
|
||||
};
|
||||
|
||||
export type WidgetOutput = {
|
||||
type: string;
|
||||
llmContext: string;
|
||||
data: any;
|
||||
};
|
||||
|
||||
export type ClassifierInput = {
|
||||
llm: BaseLLM<any>;
|
||||
enabledSources: SearchSources[];
|
||||
query: string;
|
||||
chatHistory: ChatTurnMessage[];
|
||||
};
|
||||
|
||||
export type ClassifierOutput = {
|
||||
classification: {
|
||||
skipSearch: boolean;
|
||||
personalSearch: boolean;
|
||||
academicSearch: boolean;
|
||||
discussionSearch: boolean;
|
||||
showWeatherWidget: boolean;
|
||||
showStockWidget: boolean;
|
||||
showCalculationWidget: boolean;
|
||||
};
|
||||
standaloneFollowUp: string;
|
||||
};
|
||||
|
||||
export type AdditionalConfig = {
|
||||
llm: BaseLLM<any>;
|
||||
embedding: BaseEmbedding<any>;
|
||||
session: SessionManager;
|
||||
};
|
||||
|
||||
export type ResearcherInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
followUp: string;
|
||||
classification: ClassifierOutput;
|
||||
config: SearchAgentConfig;
|
||||
};
|
||||
|
||||
export type ResearcherOutput = {
|
||||
findings: ActionOutput[];
|
||||
searchFindings: Chunk[];
|
||||
};
|
||||
|
||||
export type SearchActionOutput = {
|
||||
type: 'search_results';
|
||||
results: Chunk[];
|
||||
};
|
||||
|
||||
export type DoneActionOutput = {
|
||||
type: 'done';
|
||||
};
|
||||
|
||||
export type ReasoningResearchAction = {
|
||||
type: 'reasoning';
|
||||
reasoning: string;
|
||||
};
|
||||
|
||||
export type ActionOutput =
|
||||
| SearchActionOutput
|
||||
| DoneActionOutput
|
||||
| ReasoningResearchAction;
|
||||
|
||||
export interface ResearchAction<
|
||||
TSchema extends z.ZodObject<any> = z.ZodObject<any>,
|
||||
> {
|
||||
name: string;
|
||||
schema: z.ZodObject<any>;
|
||||
getToolDescription: (config: { mode: SearchAgentConfig['mode'] }) => string;
|
||||
getDescription: (config: { mode: SearchAgentConfig['mode'] }) => string;
|
||||
enabled: (config: {
|
||||
classification: ClassifierOutput;
|
||||
fileIds: string[];
|
||||
mode: SearchAgentConfig['mode'];
|
||||
sources: SearchSources[];
|
||||
}) => boolean;
|
||||
execute: (
|
||||
params: z.infer<TSchema>,
|
||||
additionalConfig: AdditionalConfig & {
|
||||
researchBlockId: string;
|
||||
fileIds: string[];
|
||||
},
|
||||
) => Promise<ActionOutput>;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import z from 'zod';
|
||||
import { Widget } from '../types';
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
import { exp, evaluate as mathEval } from 'mathjs';
|
||||
|
||||
const schema = z.object({
|
||||
expression: z
|
||||
.string()
|
||||
.describe('Mathematical expression to calculate or evaluate.'),
|
||||
notPresent: z
|
||||
.boolean()
|
||||
.describe('Whether there is any need for the calculation widget.'),
|
||||
});
|
||||
|
||||
const system = `
|
||||
<role>
|
||||
Assistant is a calculation expression extractor. You will recieve a user follow up and a conversation history.
|
||||
Your task is to determine if there is a mathematical expression that needs to be calculated or evaluated. If there is, extract the expression and return it. If there is no need for any calculation, set notPresent to true.
|
||||
</role>
|
||||
|
||||
<instructions>
|
||||
Make sure that the extracted expression is valid and can be used to calculate the result with Math JS library (https://mathjs.org/). If the expression is not valid, set notPresent to true.
|
||||
If you feel like you cannot extract a valid expression, set notPresent to true.
|
||||
</instructions>
|
||||
|
||||
<output_format>
|
||||
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||
{
|
||||
"expression": string,
|
||||
"notPresent": boolean
|
||||
}
|
||||
</output_format>
|
||||
`;
|
||||
|
||||
const calculationWidget: Widget = {
|
||||
type: 'calculationWidget',
|
||||
shouldExecute: (classification) =>
|
||||
classification.classification.showCalculationWidget,
|
||||
execute: async (input) => {
|
||||
const output = await input.llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: system,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
|
||||
},
|
||||
],
|
||||
schema,
|
||||
});
|
||||
|
||||
if (output.notPresent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = mathEval(output.expression);
|
||||
|
||||
return {
|
||||
type: 'calculation_result',
|
||||
llmContext: `The result of the calculation for the expression "${output.expression}" is: ${result}`,
|
||||
data: {
|
||||
expression: output.expression,
|
||||
result,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
export default calculationWidget;
|
||||
36
apps/chat-service/src/lib/agents/search/widgets/executor.ts
Normal file
36
apps/chat-service/src/lib/agents/search/widgets/executor.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Widget, WidgetInput, WidgetOutput } from '../types';
|
||||
|
||||
class WidgetExecutor {
|
||||
static widgets = new Map<string, Widget>();
|
||||
|
||||
static register(widget: Widget) {
|
||||
this.widgets.set(widget.type, widget);
|
||||
}
|
||||
|
||||
static getWidget(type: string): Widget | undefined {
|
||||
return this.widgets.get(type);
|
||||
}
|
||||
|
||||
static async executeAll(input: WidgetInput): Promise<WidgetOutput[]> {
|
||||
const results: WidgetOutput[] = [];
|
||||
|
||||
await Promise.all(
|
||||
Array.from(this.widgets.values()).map(async (widget) => {
|
||||
try {
|
||||
if (widget.shouldExecute(input.classification)) {
|
||||
const output = await widget.execute(input);
|
||||
if (output) {
|
||||
results.push(output);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`Error executing widget ${widget.type}:`, e);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export default WidgetExecutor;
|
||||
10
apps/chat-service/src/lib/agents/search/widgets/index.ts
Normal file
10
apps/chat-service/src/lib/agents/search/widgets/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import calculationWidget from './calculationWidget';
|
||||
import WidgetExecutor from './executor';
|
||||
import weatherWidget from './weatherWidget';
|
||||
import stockWidget from './stockWidget';
|
||||
|
||||
WidgetExecutor.register(weatherWidget);
|
||||
WidgetExecutor.register(calculationWidget);
|
||||
WidgetExecutor.register(stockWidget);
|
||||
|
||||
export { WidgetExecutor };
|
||||
434
apps/chat-service/src/lib/agents/search/widgets/stockWidget.ts
Normal file
434
apps/chat-service/src/lib/agents/search/widgets/stockWidget.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import z from 'zod';
|
||||
import { Widget } from '../types';
|
||||
import YahooFinance from 'yahoo-finance2';
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
|
||||
const yf = new YahooFinance({
|
||||
suppressNotices: ['yahooSurvey'],
|
||||
});
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.describe(
|
||||
"The stock name for example Nvidia, Google, Apple, Microsoft etc. You can also return ticker if you're aware of it otherwise just use the name.",
|
||||
),
|
||||
comparisonNames: z
|
||||
.array(z.string())
|
||||
.max(3)
|
||||
.describe(
|
||||
"Optional array of up to 3 stock names to compare against the base name (e.g., ['Microsoft', 'GOOGL', 'Meta']). Charts will show percentage change comparison.",
|
||||
),
|
||||
notPresent: z
|
||||
.boolean()
|
||||
.describe('Whether there is no need for the stock widget.'),
|
||||
});
|
||||
|
||||
const systemPrompt = `
|
||||
<role>
|
||||
You are a stock ticker/name extractor. You will receive a user follow up and a conversation history.
|
||||
Your task is to determine if the user is asking about stock information and extract the stock name(s) they want data for.
|
||||
</role>
|
||||
|
||||
<instructions>
|
||||
- If the user is asking about a stock, extract the primary stock name or ticker.
|
||||
- If the user wants to compare stocks, extract up to 3 comparison stock names in comparisonNames.
|
||||
- You can use either stock names (e.g., "Nvidia", "Apple") or tickers (e.g., "NVDA", "AAPL").
|
||||
- If you cannot determine a valid stock or the query is not stock-related, set notPresent to true.
|
||||
- If no comparison is needed, set comparisonNames to an empty array.
|
||||
</instructions>
|
||||
|
||||
<output_format>
|
||||
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||
{
|
||||
"name": string,
|
||||
"comparisonNames": string[],
|
||||
"notPresent": boolean
|
||||
}
|
||||
</output_format>
|
||||
`;
|
||||
|
||||
const stockWidget: Widget = {
|
||||
type: 'stockWidget',
|
||||
shouldExecute: (classification) =>
|
||||
classification.classification.showStockWidget,
|
||||
execute: async (input) => {
|
||||
const output = await input.llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
|
||||
},
|
||||
],
|
||||
schema,
|
||||
});
|
||||
|
||||
if (output.notPresent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = output;
|
||||
try {
|
||||
const name = params.name;
|
||||
|
||||
const findings = await yf.search(name);
|
||||
|
||||
if (findings.quotes.length === 0)
|
||||
throw new Error(`Failed to find quote for name/symbol: ${name}`);
|
||||
|
||||
const ticker = findings.quotes[0].symbol as string;
|
||||
|
||||
const quote: any = await yf.quote(ticker);
|
||||
|
||||
const chartPromises = {
|
||||
'1D': yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
|
||||
period2: new Date(),
|
||||
interval: '5m',
|
||||
})
|
||||
.catch(() => null),
|
||||
'5D': yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
|
||||
period2: new Date(),
|
||||
interval: '15m',
|
||||
})
|
||||
.catch(() => null),
|
||||
'1M': yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
'3M': yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
'6M': yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
'1Y': yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
MAX: yf
|
||||
.chart(ticker, {
|
||||
period1: new Date(Date.now() - 10 * 365 * 24 * 60 * 60 * 1000),
|
||||
interval: '1wk',
|
||||
})
|
||||
.catch(() => null),
|
||||
};
|
||||
|
||||
const charts = await Promise.all([
|
||||
chartPromises['1D'],
|
||||
chartPromises['5D'],
|
||||
chartPromises['1M'],
|
||||
chartPromises['3M'],
|
||||
chartPromises['6M'],
|
||||
chartPromises['1Y'],
|
||||
chartPromises['MAX'],
|
||||
]);
|
||||
|
||||
const [chart1D, chart5D, chart1M, chart3M, chart6M, chart1Y, chartMAX] =
|
||||
charts;
|
||||
|
||||
if (!quote) {
|
||||
throw new Error(`No data found for ticker: ${ticker}`);
|
||||
}
|
||||
|
||||
let comparisonData: any = null;
|
||||
if (params.comparisonNames.length > 0) {
|
||||
const comparisonPromises = params.comparisonNames
|
||||
.slice(0, 3)
|
||||
.map(async (compName) => {
|
||||
try {
|
||||
const compFindings = await yf.search(compName);
|
||||
|
||||
if (compFindings.quotes.length === 0) return null;
|
||||
|
||||
const compTicker = compFindings.quotes[0].symbol as string;
|
||||
const compQuote = await yf.quote(compTicker);
|
||||
const compCharts = await Promise.all([
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000),
|
||||
period2: new Date(),
|
||||
interval: '5m',
|
||||
})
|
||||
.catch(() => null),
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000),
|
||||
period2: new Date(),
|
||||
interval: '15m',
|
||||
})
|
||||
.catch(() => null),
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(Date.now() - 180 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(Date.now() - 365 * 24 * 60 * 60 * 1000),
|
||||
interval: '1d',
|
||||
})
|
||||
.catch(() => null),
|
||||
yf
|
||||
.chart(compTicker, {
|
||||
period1: new Date(
|
||||
Date.now() - 10 * 365 * 24 * 60 * 60 * 1000,
|
||||
),
|
||||
interval: '1wk',
|
||||
})
|
||||
.catch(() => null),
|
||||
]);
|
||||
return {
|
||||
ticker: compTicker,
|
||||
name: compQuote.shortName || compTicker,
|
||||
charts: compCharts,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to fetch comparison ticker ${compName}:`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const compResults = await Promise.all(comparisonPromises);
|
||||
comparisonData = compResults.filter((r) => r !== null);
|
||||
}
|
||||
|
||||
const stockData = {
|
||||
symbol: quote.symbol,
|
||||
shortName: quote.shortName || quote.longName || ticker,
|
||||
longName: quote.longName,
|
||||
exchange: quote.fullExchangeName || quote.exchange,
|
||||
currency: quote.currency,
|
||||
quoteType: quote.quoteType,
|
||||
|
||||
marketState: quote.marketState,
|
||||
regularMarketTime: quote.regularMarketTime,
|
||||
postMarketTime: quote.postMarketTime,
|
||||
preMarketTime: quote.preMarketTime,
|
||||
|
||||
regularMarketPrice: quote.regularMarketPrice,
|
||||
regularMarketChange: quote.regularMarketChange,
|
||||
regularMarketChangePercent: quote.regularMarketChangePercent,
|
||||
regularMarketPreviousClose: quote.regularMarketPreviousClose,
|
||||
regularMarketOpen: quote.regularMarketOpen,
|
||||
regularMarketDayHigh: quote.regularMarketDayHigh,
|
||||
regularMarketDayLow: quote.regularMarketDayLow,
|
||||
|
||||
postMarketPrice: quote.postMarketPrice,
|
||||
postMarketChange: quote.postMarketChange,
|
||||
postMarketChangePercent: quote.postMarketChangePercent,
|
||||
preMarketPrice: quote.preMarketPrice,
|
||||
preMarketChange: quote.preMarketChange,
|
||||
preMarketChangePercent: quote.preMarketChangePercent,
|
||||
|
||||
regularMarketVolume: quote.regularMarketVolume,
|
||||
averageDailyVolume3Month: quote.averageDailyVolume3Month,
|
||||
averageDailyVolume10Day: quote.averageDailyVolume10Day,
|
||||
bid: quote.bid,
|
||||
bidSize: quote.bidSize,
|
||||
ask: quote.ask,
|
||||
askSize: quote.askSize,
|
||||
|
||||
fiftyTwoWeekLow: quote.fiftyTwoWeekLow,
|
||||
fiftyTwoWeekHigh: quote.fiftyTwoWeekHigh,
|
||||
fiftyTwoWeekChange: quote.fiftyTwoWeekChange,
|
||||
fiftyTwoWeekChangePercent: quote.fiftyTwoWeekChangePercent,
|
||||
|
||||
marketCap: quote.marketCap,
|
||||
trailingPE: quote.trailingPE,
|
||||
forwardPE: quote.forwardPE,
|
||||
priceToBook: quote.priceToBook,
|
||||
bookValue: quote.bookValue,
|
||||
earningsPerShare: quote.epsTrailingTwelveMonths,
|
||||
epsForward: quote.epsForward,
|
||||
|
||||
dividendRate: quote.dividendRate,
|
||||
dividendYield: quote.dividendYield,
|
||||
exDividendDate: quote.exDividendDate,
|
||||
trailingAnnualDividendRate: quote.trailingAnnualDividendRate,
|
||||
trailingAnnualDividendYield: quote.trailingAnnualDividendYield,
|
||||
|
||||
beta: quote.beta,
|
||||
|
||||
fiftyDayAverage: quote.fiftyDayAverage,
|
||||
fiftyDayAverageChange: quote.fiftyDayAverageChange,
|
||||
fiftyDayAverageChangePercent: quote.fiftyDayAverageChangePercent,
|
||||
twoHundredDayAverage: quote.twoHundredDayAverage,
|
||||
twoHundredDayAverageChange: quote.twoHundredDayAverageChange,
|
||||
twoHundredDayAverageChangePercent:
|
||||
quote.twoHundredDayAverageChangePercent,
|
||||
|
||||
sector: quote.sector,
|
||||
industry: quote.industry,
|
||||
website: quote.website,
|
||||
|
||||
chartData: {
|
||||
'1D': chart1D
|
||||
? {
|
||||
timestamps: chart1D.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chart1D.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'5D': chart5D
|
||||
? {
|
||||
timestamps: chart5D.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chart5D.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'1M': chart1M
|
||||
? {
|
||||
timestamps: chart1M.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chart1M.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'3M': chart3M
|
||||
? {
|
||||
timestamps: chart3M.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chart3M.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'6M': chart6M
|
||||
? {
|
||||
timestamps: chart6M.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chart6M.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'1Y': chart1Y
|
||||
? {
|
||||
timestamps: chart1Y.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chart1Y.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
MAX: chartMAX
|
||||
? {
|
||||
timestamps: chartMAX.quotes.map((q: any) => q.date.getTime()),
|
||||
prices: chartMAX.quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
comparisonData: comparisonData
|
||||
? comparisonData.map((comp: any) => ({
|
||||
ticker: comp.ticker,
|
||||
name: comp.name,
|
||||
chartData: {
|
||||
'1D': comp.charts[0]
|
||||
? {
|
||||
timestamps: comp.charts[0].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[0].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'5D': comp.charts[1]
|
||||
? {
|
||||
timestamps: comp.charts[1].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[1].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'1M': comp.charts[2]
|
||||
? {
|
||||
timestamps: comp.charts[2].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[2].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'3M': comp.charts[3]
|
||||
? {
|
||||
timestamps: comp.charts[3].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[3].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'6M': comp.charts[4]
|
||||
? {
|
||||
timestamps: comp.charts[4].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[4].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
'1Y': comp.charts[5]
|
||||
? {
|
||||
timestamps: comp.charts[5].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[5].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
MAX: comp.charts[6]
|
||||
? {
|
||||
timestamps: comp.charts[6].quotes.map((q: any) =>
|
||||
q.date.getTime(),
|
||||
),
|
||||
prices: comp.charts[6].quotes.map((q: any) => q.close),
|
||||
}
|
||||
: null,
|
||||
},
|
||||
}))
|
||||
: null,
|
||||
};
|
||||
|
||||
return {
|
||||
type: 'stock',
|
||||
llmContext: `Current price of ${stockData.shortName} (${stockData.symbol}) is ${stockData.regularMarketPrice} ${stockData.currency}. Other details: ${JSON.stringify(
|
||||
{
|
||||
marketState: stockData.marketState,
|
||||
regularMarketChange: stockData.regularMarketChange,
|
||||
regularMarketChangePercent: stockData.regularMarketChangePercent,
|
||||
marketCap: stockData.marketCap,
|
||||
peRatio: stockData.trailingPE,
|
||||
dividendYield: stockData.dividendYield,
|
||||
},
|
||||
)}`,
|
||||
data: stockData,
|
||||
};
|
||||
} catch (error: any) {
|
||||
return {
|
||||
type: 'stock',
|
||||
llmContext: 'Failed to fetch stock data.',
|
||||
data: {
|
||||
error: `Error fetching stock data: ${error.message || error}`,
|
||||
ticker: params.name,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default stockWidget;
|
||||
203
apps/chat-service/src/lib/agents/search/widgets/weatherWidget.ts
Normal file
203
apps/chat-service/src/lib/agents/search/widgets/weatherWidget.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import z from 'zod';
|
||||
import { Widget } from '../types';
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
|
||||
const schema = z.object({
|
||||
location: z
|
||||
.string()
|
||||
.describe(
|
||||
'Human-readable location name (e.g., "New York, NY, USA", "London, UK"). Use this OR lat/lon coordinates, never both. Leave empty string if providing coordinates.',
|
||||
),
|
||||
lat: z
|
||||
.number()
|
||||
.describe(
|
||||
'Latitude coordinate in decimal degrees (e.g., 40.7128). Only use when location name is empty.',
|
||||
),
|
||||
lon: z
|
||||
.number()
|
||||
.describe(
|
||||
'Longitude coordinate in decimal degrees (e.g., -74.0060). Only use when location name is empty.',
|
||||
),
|
||||
notPresent: z
|
||||
.boolean()
|
||||
.describe('Whether there is no need for the weather widget.'),
|
||||
});
|
||||
|
||||
const systemPrompt = `
|
||||
<role>
|
||||
You are a location extractor for weather queries. You will receive a user follow up and a conversation history.
|
||||
Your task is to determine if the user is asking about weather and extract the location they want weather for.
|
||||
</role>
|
||||
|
||||
<instructions>
|
||||
- If the user is asking about weather, extract the location name OR coordinates (never both).
|
||||
- If using location name, set lat and lon to 0.
|
||||
- If using coordinates, set location to empty string.
|
||||
- If you cannot determine a valid location or the query is not weather-related, set notPresent to true.
|
||||
- Location should be specific (city, state/region, country) for best results.
|
||||
- You have to give the location so that it can be used to fetch weather data, it cannot be left empty unless notPresent is true.
|
||||
- Make sure to infer short forms of location names (e.g., "NYC" -> "New York City", "LA" -> "Los Angeles").
|
||||
</instructions>
|
||||
|
||||
<output_format>
|
||||
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||
{
|
||||
"location": string,
|
||||
"lat": number,
|
||||
"lon": number,
|
||||
"notPresent": boolean
|
||||
}
|
||||
</output_format>
|
||||
`;
|
||||
|
||||
const weatherWidget: Widget = {
|
||||
type: 'weatherWidget',
|
||||
shouldExecute: (classification) =>
|
||||
classification.classification.showWeatherWidget,
|
||||
execute: async (input) => {
|
||||
const output = await input.llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<conversation_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</conversation_history>\n<user_follow_up>\n${input.followUp}\n</user_follow_up>`,
|
||||
},
|
||||
],
|
||||
schema,
|
||||
});
|
||||
|
||||
if (output.notPresent) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params = output;
|
||||
|
||||
try {
|
||||
if (
|
||||
params.location === '' &&
|
||||
(params.lat === undefined || params.lon === undefined)
|
||||
) {
|
||||
throw new Error(
|
||||
'Either location name or both latitude and longitude must be provided.',
|
||||
);
|
||||
}
|
||||
|
||||
if (params.location !== '') {
|
||||
const openStreetMapUrl = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(params.location)}&format=json&limit=1`;
|
||||
|
||||
const locationRes = await fetch(openStreetMapUrl, {
|
||||
headers: {
|
||||
'User-Agent': 'GooSeek',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
const data = await locationRes.json();
|
||||
|
||||
const location = data[0];
|
||||
|
||||
if (!location) {
|
||||
throw new Error(
|
||||
`Could not find coordinates for location: ${params.location}`,
|
||||
);
|
||||
}
|
||||
|
||||
const weatherRes = await fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${location.lat}&longitude=${location.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'GooSeek',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const weatherData = await weatherRes.json();
|
||||
|
||||
return {
|
||||
type: 'weather',
|
||||
llmContext: `Weather in ${params.location} is ${JSON.stringify(weatherData.current)}`,
|
||||
data: {
|
||||
location: params.location,
|
||||
latitude: location.lat,
|
||||
longitude: location.lon,
|
||||
current: weatherData.current,
|
||||
hourly: {
|
||||
time: weatherData.hourly.time.slice(0, 24),
|
||||
temperature_2m: weatherData.hourly.temperature_2m.slice(0, 24),
|
||||
precipitation_probability:
|
||||
weatherData.hourly.precipitation_probability.slice(0, 24),
|
||||
precipitation: weatherData.hourly.precipitation.slice(0, 24),
|
||||
weather_code: weatherData.hourly.weather_code.slice(0, 24),
|
||||
},
|
||||
daily: weatherData.daily,
|
||||
timezone: weatherData.timezone,
|
||||
},
|
||||
};
|
||||
} else if (params.lat !== undefined && params.lon !== undefined) {
|
||||
const [weatherRes, locationRes] = await Promise.all([
|
||||
fetch(
|
||||
`https://api.open-meteo.com/v1/forecast?latitude=${params.lat}&longitude=${params.lon}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,showers,snowfall,weather_code,cloud_cover,pressure_msl,surface_pressure,wind_speed_10m,wind_direction_10m,wind_gusts_10m&hourly=temperature_2m,precipitation_probability,precipitation,weather_code&daily=weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,precipitation_probability_max&timezone=auto&forecast_days=7`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'GooSeek',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'GooSeek',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
),
|
||||
]);
|
||||
|
||||
const weatherData = await weatherRes.json();
|
||||
const locationData = await locationRes.json();
|
||||
|
||||
return {
|
||||
type: 'weather',
|
||||
llmContext: `Weather in ${locationData.display_name} is ${JSON.stringify(weatherData.current)}`,
|
||||
data: {
|
||||
location: locationData.display_name,
|
||||
latitude: params.lat,
|
||||
longitude: params.lon,
|
||||
current: weatherData.current,
|
||||
hourly: {
|
||||
time: weatherData.hourly.time.slice(0, 24),
|
||||
temperature_2m: weatherData.hourly.temperature_2m.slice(0, 24),
|
||||
precipitation_probability:
|
||||
weatherData.hourly.precipitation_probability.slice(0, 24),
|
||||
precipitation: weatherData.hourly.precipitation.slice(0, 24),
|
||||
weather_code: weatherData.hourly.weather_code.slice(0, 24),
|
||||
},
|
||||
daily: weatherData.daily,
|
||||
timezone: weatherData.timezone,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'weather',
|
||||
llmContext: 'No valid location or coordinates provided.',
|
||||
data: null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
type: 'weather',
|
||||
llmContext: 'Failed to fetch weather data.',
|
||||
data: {
|
||||
error: `Error fetching weather data: ${err}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
export default weatherWidget;
|
||||
38
apps/chat-service/src/lib/agents/suggestions/index.ts
Normal file
38
apps/chat-service/src/lib/agents/suggestions/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
|
||||
import { suggestionGeneratorPrompt } from '@/lib/prompts/suggestions';
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
import z from 'zod';
|
||||
import BaseLLM from '@/lib/models/base/llm';
|
||||
|
||||
type SuggestionGeneratorInput = {
|
||||
chatHistory: ChatTurnMessage[];
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
suggestions: z
|
||||
.array(z.string())
|
||||
.describe('List of suggested questions or prompts'),
|
||||
});
|
||||
|
||||
const generateSuggestions = async (
|
||||
input: SuggestionGeneratorInput,
|
||||
llm: BaseLLM<any>,
|
||||
) => {
|
||||
const res = await llm.generateObject<typeof schema>({
|
||||
messages: [
|
||||
{
|
||||
role: 'system',
|
||||
content: suggestionGeneratorPrompt,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `<chat_history>\n${formatChatHistoryAsString(input.chatHistory)}\n</chat_history>`,
|
||||
},
|
||||
],
|
||||
schema,
|
||||
});
|
||||
|
||||
return res.suggestions;
|
||||
};
|
||||
|
||||
export default generateSuggestions;
|
||||
30
apps/chat-service/src/lib/prompts/media/image.ts
Normal file
30
apps/chat-service/src/lib/prompts/media/image.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
|
||||
export const imageSearchPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search the web for images.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
Make sure to make the querey standalone and not something very broad, use context from the answers in the conversation to make it specific so user can get best image search results.
|
||||
Output only the rephrased query in query key JSON format. Do not include any explanation or additional text.
|
||||
`;
|
||||
|
||||
export const imageSearchFewShots: ChatTurnMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<conversation>\n</conversation>\n<follow_up>\nWhat is a cat?\n</follow_up>',
|
||||
},
|
||||
{ role: 'assistant', content: '{"query":"A cat"}' },
|
||||
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<conversation>\n</conversation>\n<follow_up>\nWhat is a car? How does it work?\n</follow_up>',
|
||||
},
|
||||
{ role: 'assistant', content: '{"query":"Car working"}' },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
|
||||
},
|
||||
{ role: 'assistant', content: '{"query":"AC working"}' },
|
||||
];
|
||||
29
apps/chat-service/src/lib/prompts/media/videos.ts
Normal file
29
apps/chat-service/src/lib/prompts/media/videos.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { ChatTurnMessage } from '@/lib/types';
|
||||
|
||||
export const videoSearchPrompt = `
|
||||
You will be given a conversation below and a follow up question. You need to rephrase the follow-up question so it is a standalone question that can be used by the LLM to search Youtube for videos.
|
||||
You need to make sure the rephrased question agrees with the conversation and is relevant to the conversation.
|
||||
Make sure to make the querey standalone and not something very broad, use context from the answers in the conversation to make it specific so user can get best video search results.
|
||||
Output only the rephrased query in query key JSON format. Do not include any explanation or additional text.
|
||||
`;
|
||||
|
||||
export const videoSearchFewShots: ChatTurnMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<conversation>\n</conversation>\n<follow_up>\nHow does a car work?\n</follow_up>',
|
||||
},
|
||||
{ role: 'assistant', content: '{"query":"How does a car work?"}' },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<conversation>\n</conversation>\n<follow_up>\nWhat is the theory of relativity?\n</follow_up>',
|
||||
},
|
||||
{ role: 'assistant', content: '{"query":"Theory of relativity"}' },
|
||||
{
|
||||
role: 'user',
|
||||
content:
|
||||
'<conversation>\n</conversation>\n<follow_up>\nHow does an AC work?\n</follow_up>',
|
||||
},
|
||||
{ role: 'assistant', content: '{"query":"AC working"}' },
|
||||
];
|
||||
64
apps/chat-service/src/lib/prompts/search/classifier.ts
Normal file
64
apps/chat-service/src/lib/prompts/search/classifier.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
export const classifierPrompt = `
|
||||
<role>
|
||||
Assistant is an advanced AI system designed to analyze the user query and the conversation history to determine the most appropriate classification for the search operation.
|
||||
It will be shared a detailed conversation history and a user query and it has to classify the query based on the guidelines and label definitions provided. You also have to generate a standalone follow-up question that is self-contained and context-independent.
|
||||
</role>
|
||||
|
||||
<labels>
|
||||
NOTE: BY GENERAL KNOWLEDGE WE MEAN INFORMATION THAT IS OBVIOUS, WIDELY KNOWN, OR CAN BE INFERRED WITHOUT EXTERNAL SOURCES FOR EXAMPLE MATHEMATICAL FACTS, BASIC SCIENTIFIC KNOWLEDGE, COMMON HISTORICAL EVENTS, ETC.
|
||||
1. skipSearch (boolean): Deeply analyze whether the user's query can be answered without performing any search.
|
||||
- Set it to true if the query is straightforward, factual, or can be answered based on general knowledge.
|
||||
- Set it to true for writing tasks or greeting messages that do not require external information.
|
||||
- Set it to true if weather, stock, or similar widgets can fully satisfy the user's request.
|
||||
- Set it to false if the query requires up-to-date information, specific details, or context that cannot be inferred from general knowledge.
|
||||
- ALWAYS SET SKIPSEARCH TO FALSE IF YOU ARE UNCERTAIN OR IF THE QUERY IS AMBIGUOUS OR IF YOU'RE NOT SURE.
|
||||
2. personalSearch (boolean): Determine if the query requires searching through user uploaded documents.
|
||||
- Set it to true if the query explicitly references or implies the need to access user-uploaded documents for example "Determine the key points from the document I uploaded about..." or "Who is the author?", "Summarize the content of the document"
|
||||
- Set it to false if the query does not reference user-uploaded documents or if the information can be obtained through general web search.
|
||||
- ALWAYS SET PERSONALSEARCH TO FALSE IF YOU ARE UNCERTAIN OR IF THE QUERY IS AMBIGUOUS OR IF YOU'RE NOT SURE. AND SET SKIPSEARCH TO FALSE AS WELL.
|
||||
3. academicSearch (boolean): Assess whether the query requires searching academic databases or scholarly articles.
|
||||
- Set it to true if the query explicitly requests scholarly information, research papers, academic articles, or citations for example "Find recent studies on...", "What does the latest research say about...", or "Provide citations for..."
|
||||
- Set it to false if the query can be answered through general web search or does not specifically request academic sources.
|
||||
4. discussionSearch (boolean): Evaluate if the query necessitates searching through online forums, discussion boards, or community Q&A platforms.
|
||||
- Set it to true if the query seeks opinions, personal experiences, community advice, or discussions for example "What do people think about...", "Are there any discussions on...", or "What are the common issues faced by..."
|
||||
- Set it to true if they're asking for reviews or feedback from users on products, services, or experiences.
|
||||
- Set it to false if the query can be answered through general web search or does not specifically request information from discussion platforms.
|
||||
5. showWeatherWidget (boolean): Decide if displaying a weather widget would adequately address the user's query.
|
||||
- Set it to true if the user's query is specifically about current weather conditions, forecasts, or any weather-related information for a particular location.
|
||||
- Set it to true for queries like "What's the weather like in [Location]?" or "Will it rain tomorrow in [Location]?" or "Show me the weather" (Here they mean weather of their current location).
|
||||
- If it can fully answer the user query without needing additional search, set skipSearch to true as well.
|
||||
6. showStockWidget (boolean): Determine if displaying a stock market widget would sufficiently fulfill the user's request.
|
||||
- Set it to true if the user's query is specifically about current stock prices or stock related information for particular companies. Never use it for a market analysis or news about stock market.
|
||||
- Set it to true for queries like "What's the stock price of [Company]?" or "How is the [Stock] performing today?" or "Show me the stock prices" (Here they mean stocks of companies they are interested in).
|
||||
- If it can fully answer the user query without needing additional search, set skipSearch to true as well.
|
||||
7. showCalculationWidget (boolean): Decide if displaying a calculation widget would adequately address the user's query.
|
||||
- Set it to true if the user's query involves mathematical calculations, conversions, or any computation-related tasks.
|
||||
- Set it to true for queries like "What is 25% of 80?" or "Convert 100 USD to EUR" or "Calculate the square root of 256" or "What is 2 * 3 + 5?" or other mathematical expressions.
|
||||
- If it can fully answer the user query without needing additional search, set skipSearch to true as well.
|
||||
</labels>
|
||||
|
||||
<standalone_followup>
|
||||
For the standalone follow up, you have to generate a self contained, context independant reformulation of the user's query.
|
||||
You basically have to rephrase the user's query in a way that it can be understood without any prior context from the conversation history.
|
||||
Say for example the converastion is about cars and the user says "How do they work" then the standalone follow up should be "How do cars work?"
|
||||
|
||||
Do not contain excess information or everything that has been discussed before, just reformulate the user's last query in a self contained manner.
|
||||
The standalone follow-up should be concise and to the point.
|
||||
</standalone_followup>
|
||||
|
||||
<output_format>
|
||||
You must respond in the following JSON format without any extra text, explanations or filler sentences:
|
||||
{
|
||||
"classification": {
|
||||
"skipSearch": boolean,
|
||||
"personalSearch": boolean,
|
||||
"academicSearch": boolean,
|
||||
"discussionSearch": boolean,
|
||||
"showWeatherWidget": boolean,
|
||||
"showStockWidget": boolean,
|
||||
"showCalculationWidget": boolean,
|
||||
},
|
||||
"standaloneFollowUp": string
|
||||
}
|
||||
</output_format>
|
||||
`;
|
||||
54
apps/chat-service/src/lib/prompts/search/writer.ts
Normal file
54
apps/chat-service/src/lib/prompts/search/writer.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export const getWriterPrompt = (
|
||||
context: string,
|
||||
systemInstructions: string,
|
||||
mode: 'speed' | 'balanced' | 'quality',
|
||||
) => {
|
||||
return `
|
||||
You are GooSeek, an AI model skilled in web search and crafting detailed, engaging, and well-structured answers. You excel at summarizing web pages and extracting relevant information to create professional, blog-style responses.
|
||||
|
||||
Your task is to provide answers that are:
|
||||
- **Informative and relevant**: Thoroughly address the user's query using the given context.
|
||||
- **Well-structured**: Include clear headings and subheadings, and use a professional tone to present information concisely and logically.
|
||||
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights.
|
||||
- **Cited and credible**: Use inline citations with [number] notation to refer to the context source(s) for each fact or detail included.
|
||||
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable.
|
||||
|
||||
### Formatting Instructions
|
||||
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate.
|
||||
- **Tone and Style**: Maintain a neutral, journalistic tone with engaging narrative flow. Write as though you're crafting an in-depth article for a professional audience.
|
||||
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability.
|
||||
- **Length and Depth**: Provide comprehensive coverage of the topic. Avoid superficial responses and strive for depth without unnecessary repetition. Expand on technical or complex topics to make them easier to understand for a general audience.
|
||||
- **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title.
|
||||
- **Conclusion or Summary**: Include a concluding paragraph that synthesizes the provided information or suggests potential next steps, where appropriate.
|
||||
|
||||
### Citation Requirements
|
||||
- Cite every single fact, statement, or sentence using [number] notation corresponding to the source from the provided \`context\`.
|
||||
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
|
||||
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context.
|
||||
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
|
||||
- Always prioritize credibility and accuracy by linking all statements back to their respective context sources.
|
||||
- Avoid citing unsupported assumptions or personal interpretations; if no source supports a statement, clearly indicate the limitation.
|
||||
|
||||
### Special Instructions
|
||||
- If the query involves technical, historical, or complex topics, provide detailed background and explanatory sections to ensure clarity.
|
||||
- If the user provides vague input or if relevant information is missing, explain what additional details might help refine the search.
|
||||
- If no relevant information is found, say: "Hmm, sorry I could not find any relevant information on this topic. Would you like me to search again or ask something else?" Be transparent about limitations and suggest alternatives or ways to reframe the query.
|
||||
${mode === 'quality' ? "- YOU ARE CURRENTLY SET IN QUALITY MODE, GENERATE VERY DEEP, DETAILED AND COMPREHENSIVE RESPONSES USING THE FULL CONTEXT PROVIDED. ASSISTANT'S RESPONSES SHALL NOT BE LESS THAN AT LEAST 2000 WORDS, COVER EVERYTHING AND FRAME IT LIKE A RESEARCH REPORT." : ''}
|
||||
|
||||
### User instructions
|
||||
These instructions are shared to you by the user and not by the system. You will have to follow them but give them less priority than the above instructions. If the user has provided specific instructions or preferences, incorporate them into your response while adhering to the overall guidelines.
|
||||
${systemInstructions}
|
||||
|
||||
### Example Output
|
||||
- Begin with a brief introduction summarizing the event or query topic.
|
||||
- Follow with detailed sections under clear headings, covering all aspects of the query if possible.
|
||||
- Provide explanations or historical context as needed to enhance understanding.
|
||||
- End with a conclusion or overall perspective if relevant.
|
||||
|
||||
<context>
|
||||
${context}
|
||||
</context>
|
||||
|
||||
Current date & time in ISO format (UTC timezone) is: ${new Date().toISOString()}.
|
||||
`;
|
||||
};
|
||||
17
apps/chat-service/src/lib/prompts/suggestions/index.ts
Normal file
17
apps/chat-service/src/lib/prompts/suggestions/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export const suggestionGeneratorPrompt = `
|
||||
You are an AI suggestion generator for an AI powered search engine. You will be given a conversation below. You need to generate 4-5 suggestions based on the conversation. The suggestion should be relevant to the conversation that can be used by the user to ask the chat model for more information.
|
||||
You need to make sure the suggestions are relevant to the conversation and are helpful to the user. Keep a note that the user might use these suggestions to ask a chat model for more information.
|
||||
Make sure the suggestions are medium in length and are informative and relevant to the conversation.
|
||||
|
||||
Sample suggestions for a conversation about Elon Musk:
|
||||
{
|
||||
"suggestions": [
|
||||
"What are Elon Musk's plans for SpaceX in the next decade?",
|
||||
"How has Tesla's stock performance been influenced by Elon Musk's leadership?",
|
||||
"What are the key innovations introduced by Elon Musk in the electric vehicle industry?",
|
||||
"How does Elon Musk's vision for renewable energy impact global sustainability efforts?"
|
||||
]
|
||||
}
|
||||
|
||||
Today's date is ${new Date().toISOString()}
|
||||
`;
|
||||
105
apps/chat-service/src/lib/session.ts
Normal file
105
apps/chat-service/src/lib/session.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { EventEmitter } from 'stream';
|
||||
import { applyPatch } from 'rfc6902';
|
||||
import { Block } from './types';
|
||||
|
||||
const sessions =
|
||||
(global as any)._sessionManagerSessions || new Map<string, SessionManager>();
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
(global as any)._sessionManagerSessions = sessions;
|
||||
}
|
||||
|
||||
class SessionManager {
|
||||
private static sessions: Map<string, SessionManager> = sessions;
|
||||
readonly id: string;
|
||||
private blocks = new Map<string, Block>();
|
||||
private events: { event: string; data: any }[] = [];
|
||||
private emitter = new EventEmitter();
|
||||
private TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
constructor(id?: string) {
|
||||
this.id = id ?? crypto.randomUUID();
|
||||
|
||||
setTimeout(() => {
|
||||
SessionManager.sessions.delete(this.id);
|
||||
}, this.TTL_MS);
|
||||
}
|
||||
|
||||
static getSession(id: string): SessionManager | undefined {
|
||||
return this.sessions.get(id);
|
||||
}
|
||||
|
||||
static getAllSessions(): SessionManager[] {
|
||||
return Array.from(this.sessions.values());
|
||||
}
|
||||
|
||||
static createSession(): SessionManager {
|
||||
const session = new SessionManager();
|
||||
this.sessions.set(session.id, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
this.emitter.removeAllListeners();
|
||||
}
|
||||
|
||||
emit(event: string, data: any) {
|
||||
this.emitter.emit(event, data);
|
||||
this.events.push({ event, data });
|
||||
}
|
||||
|
||||
emitBlock(block: Block) {
|
||||
this.blocks.set(block.id, block);
|
||||
this.emit('data', {
|
||||
type: 'block',
|
||||
block: block,
|
||||
});
|
||||
}
|
||||
|
||||
getBlock(blockId: string): Block | undefined {
|
||||
return this.blocks.get(blockId);
|
||||
}
|
||||
|
||||
updateBlock(blockId: string, patch: any[]) {
|
||||
const block = this.blocks.get(blockId);
|
||||
|
||||
if (block) {
|
||||
applyPatch(block, patch);
|
||||
this.blocks.set(blockId, block);
|
||||
this.emit('data', {
|
||||
type: 'updateBlock',
|
||||
blockId: blockId,
|
||||
patch: patch,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getAllBlocks() {
|
||||
return Array.from(this.blocks.values());
|
||||
}
|
||||
|
||||
subscribe(listener: (event: string, data: any) => void): () => void {
|
||||
const currentEventsLength = this.events.length;
|
||||
|
||||
const handler = (event: string) => (data: any) => listener(event, data);
|
||||
const dataHandler = handler('data');
|
||||
const endHandler = handler('end');
|
||||
const errorHandler = handler('error');
|
||||
|
||||
this.emitter.on('data', dataHandler);
|
||||
this.emitter.on('end', endHandler);
|
||||
this.emitter.on('error', errorHandler);
|
||||
|
||||
for (let i = 0; i < currentEventsLength; i++) {
|
||||
const { event, data } = this.events[i];
|
||||
listener(event, data);
|
||||
}
|
||||
|
||||
return () => {
|
||||
this.emitter.off('data', dataHandler);
|
||||
this.emitter.off('end', endHandler);
|
||||
this.emitter.off('error', errorHandler);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionManager;
|
||||
Reference in New Issue
Block a user