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;
|
||||
Reference in New Issue
Block a user