Compare commits

..

7 Commits

Author SHA1 Message Date
ItzCrazyKns
1614cfa5e5 feat(app): add widgets 2025-11-20 14:55:50 +05:30
ItzCrazyKns
036b44611f feat(search): add classifier 2025-11-20 14:55:24 +05:30
ItzCrazyKns
8b515201f3 feat(app): add search types 2025-11-20 14:53:03 +05:30
ItzCrazyKns
cbcb03c7ac feat(llm): update return type to partial 2025-11-20 14:52:41 +05:30
ItzCrazyKns
afc68ca91f feat(ollamaLLM): disable thinking in obj mode 2025-11-20 14:52:24 +05:30
ItzCrazyKns
3cc8882b28 feat(prompts): add classifier prompt 2025-11-20 14:51:49 +05:30
ItzCrazyKns
c3830795cb feat(app): add new session manager 2025-11-20 14:51:17 +05:30
17 changed files with 661 additions and 7 deletions

View File

@@ -0,0 +1,72 @@
import z from 'zod';
import { ClassifierInput, ClassifierOutput } from '../types';
import { WidgetRegistry } from '../widgets';
import { IntentRegistry } from './intents';
import { getClassifierPrompt } from '@/lib/prompts/search/classifier';
import formatChatHistoryAsString from '@/lib/utils/formatHistory';
class Classifier {
async classify(input: ClassifierInput): Promise<ClassifierOutput> {
const availableIntents = IntentRegistry.getAvailableIntents({
sources: input.enabledSources,
});
const availableWidgets = WidgetRegistry.getAll();
const classificationSchema = z.object({
skipSearch: z
.boolean()
.describe(
'Set to true to SKIP search. Skip ONLY when: (1) widgets alone fully answer the query (e.g., weather, stocks, calculator), (2) simple greetings or writing tasks (NOT questions). Set to false for ANY question or information request.',
),
standaloneFollowUp: z
.string()
.describe(
'A self-contained, context-independent reformulation of the user\'s question. Must include all necessary context from chat history, replace pronouns with specific nouns, and be clear enough to answer without seeing the conversation. Keep the same complexity as the original question.',
),
intents: z
.array(z.enum(availableIntents.map((i) => i.name)))
.describe(
'The intent(s) that best describe how to fulfill the user\'s query. Can include multiple intents (e.g., [\'web_search\', \'widget_response\'] for \'weather in NYC and recent news\'). Always include at least one intent when applicable.',
),
widgets: z
.array(z.union(availableWidgets.map((w) => w.schema)))
.describe(
'Widgets that can display structured data to answer (fully or partially) the query. Include all applicable widgets regardless of skipSearch value.',
),
});
const classifierPrompt = getClassifierPrompt({
intentDesc: IntentRegistry.getDescriptions({
sources: input.enabledSources,
}),
widgetDesc: WidgetRegistry.getDescriptions(),
});
const res = await input.llm.generateObject<
z.infer<typeof classificationSchema>
>({
messages: [
{
role: 'system',
content: classifierPrompt,
},
{
role: 'user',
content: `<conversation>${formatChatHistoryAsString(input.chatHistory)}</conversation>\n\n<query>${input.query}</query>`,
},
],
schema: classificationSchema,
});
res.widgets = res.widgets.map((widgetConfig) => {
return {
type: widgetConfig.type,
params: widgetConfig,
};
});
return res as ClassifierOutput;
}
}
export default Classifier;

View File

@@ -0,0 +1,11 @@
import { Intent } from '../../types';
const academicSearchIntent: Intent = {
name: 'academic_search',
description:
'Use this intent to find scholarly articles, research papers, and academic resources when the user is seeking credible and authoritative information on a specific topic.',
requiresSearch: true,
enabled: (config) => config.sources.includes('academic'),
};
export default academicSearchIntent;

View File

@@ -0,0 +1,11 @@
import { Intent } from '../../types';
const discussionSearchIntent: Intent = {
name: 'discussion_search',
description:
'Use this intent to search through discussion forums, community boards, or social media platforms when the user is looking for opinions, experiences, or community-driven information on a specific topic.',
requiresSearch: true,
enabled: (config) => config.sources.includes('discussions'),
};
export default discussionSearchIntent;

View File

@@ -0,0 +1,14 @@
import academicSearchIntent from './academicSearch';
import discussionSearchIntent from './discussionSearch';
import IntentRegistry from './registry';
import webSearchIntent from './webSearch';
import widgetResponseIntent from './widgetResponse';
import writingTaskIntent from './writingTask';
IntentRegistry.register(webSearchIntent);
IntentRegistry.register(academicSearchIntent);
IntentRegistry.register(discussionSearchIntent);
IntentRegistry.register(widgetResponseIntent);
IntentRegistry.register(writingTaskIntent);
export { IntentRegistry };

View File

@@ -0,0 +1,29 @@
import { Intent, SearchAgentConfig, SearchSources } from '../../types';
class IntentRegistry {
private static intents = new Map<string, Intent>();
static register(intent: Intent) {
this.intents.set(intent.name, intent);
}
static get(name: string): Intent | undefined {
return this.intents.get(name);
}
static getAvailableIntents(config: { sources: SearchSources[] }): Intent[] {
return Array.from(
this.intents.values().filter((intent) => intent.enabled(config)),
);
}
static getDescriptions(config: { sources: SearchSources[] }): string {
const availableintnets = this.getAvailableIntents(config);
return availableintnets
.map((intent) => `${intent.name}: ${intent.description}`)
.join('\n\n');
}
}
export default IntentRegistry;

View File

@@ -0,0 +1,11 @@
import { Intent } from '../../types';
const webSearchIntent: Intent = {
name: 'web_search',
description:
'Use this intent to find current information from the web when the user is asking a question or needs up-to-date information that cannot be provided by widgets or other intents.',
requiresSearch: true,
enabled: (config) => config.sources.includes('web'),
};
export default webSearchIntent;

View File

@@ -0,0 +1,11 @@
import { Intent } from '../../types';
const widgetResponseIntent: Intent = {
name: 'widget_response',
description:
'Use this intent to respond to user queries using available widgets when the required information can be obtained from them.',
requiresSearch: false,
enabled: (config) => true,
};
export default widgetResponseIntent;

View File

@@ -0,0 +1,11 @@
import { Intent } from '../../types';
const writingTaskIntent: Intent = {
name: 'writing_task',
description:
'Use this intent to assist users with writing tasks such as drafting emails, creating documents, or generating content based on their instructions or greetings.',
requiresSearch: false,
enabled: (config) => true,
};
export default writingTaskIntent;

View File

@@ -0,0 +1,65 @@
import { EventEmitter } from 'stream';
import z from 'zod';
import BaseLLM from '../../models/base/llm';
import BaseEmbedding from '@/lib/models/base/embedding';
export type SearchSources = 'web' | 'discussions' | 'academic';
export type SearchAgentConfig = {
sources: SearchSources[];
llm: BaseLLM<any>;
embedding: BaseEmbedding<any>;
};
export type SearchAgentInput = {
chatHistory: Message[];
followUp: string;
config: SearchAgentConfig;
};
export interface Intent {
name: string;
description: string;
requiresSearch: boolean;
enabled: (config: { sources: SearchSources[] }) => boolean;
}
export type Widget<TSchema extends z.ZodObject<any> = z.ZodObject<any>> = {
name: string;
description: string;
schema: TSchema;
execute: (
params: z.infer<TSchema>,
additionalConfig: AdditionalConfig,
) => Promise<WidgetOutput>;
};
export type WidgetConfig = {
type: string;
params: Record<string, any>;
};
export type WidgetOutput = {
type: string;
data: any;
};
export type ClassifierInput = {
llm: BaseLLM<any>;
enabledSources: SearchSources[];
query: string;
chatHistory: Message[];
};
export type ClassifierOutput = {
skipSearch: boolean;
standaloneFollowUp: string;
intents: string[];
widgets: WidgetConfig[];
};
export type AdditionalConfig = {
llm: BaseLLM<any>;
embedding: BaseLLM<any>;
emitter: EventEmitter;
};

View File

@@ -0,0 +1,6 @@
import WidgetRegistry from './registry';
import weatherWidget from './weatherWidget';
WidgetRegistry.register(weatherWidget);
export { WidgetRegistry };

View File

@@ -0,0 +1,65 @@
import {
AdditionalConfig,
SearchAgentConfig,
Widget,
WidgetConfig,
WidgetOutput,
} from '../types';
class WidgetRegistry {
private static widgets = new Map<string, Widget>();
static register(widget: Widget<any>) {
this.widgets.set(widget.name, widget);
}
static get(name: string): Widget | undefined {
return this.widgets.get(name);
}
static getAll(): Widget[] {
return Array.from(this.widgets.values());
}
static getDescriptions(): string {
return Array.from(this.widgets.values())
.map((widget) => `${widget.name}: ${widget.description}`)
.join('\n\n');
}
static async execute(
name: string,
params: any,
config: AdditionalConfig,
): Promise<WidgetOutput> {
const widget = this.get(name);
if (!widget) {
throw new Error(`Widget with name ${name} not found`);
}
return widget.execute(params, config);
}
static async executeAll(
widgets: WidgetConfig[],
additionalConfig: AdditionalConfig,
): Promise<WidgetOutput[]> {
const results: WidgetOutput[] = [];
await Promise.all(
widgets.map(async (widgetConfig) => {
const output = await this.execute(
widgetConfig.type,
widgetConfig.params,
additionalConfig,
);
results.push(output);
}),
);
return results;
}
}
export default WidgetRegistry;

View File

@@ -0,0 +1,123 @@
import z from 'zod';
import { Widget } from '../types';
const WeatherWidgetSchema = z.object({
type: z.literal('weather'),
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.',
),
});
const weatherWidget = {
name: 'weather',
description:
'Provides current weather information for a specified location. It can return details such as temperature, humidity, wind speed, and weather conditions. It needs either a location name or latitude/longitude coordinates to function.',
schema: WeatherWidgetSchema,
execute: async (params, _) => {
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': 'Perplexica',
'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}&current_weather=true`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
);
const weatherData = await weatherRes.json();
/* this is like a very simple implementation just to see the bacckend works, when we're working on the frontend, we'll return more data i guess? */
return {
type: 'weather',
data: {
location: params.location,
latitude: location.lat,
longitude: location.lon,
weather: weatherData.current_weather,
},
};
} 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}&current_weather=true`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
),
fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${params.lat}&lon=${params.lon}&format=json`,
{
headers: {
'User-Agent': 'Perplexica',
'Content-Type': 'application/json',
},
},
),
]);
const weatherData = await weatherRes.json();
const locationData = await locationRes.json();
return {
type: 'weather',
data: {
location: locationData.display_name,
latitude: params.lat,
longitude: params.lon,
weather: weatherData.current_weather,
},
};
}
return {
type: 'weather',
data: null,
};
},
} satisfies Widget<typeof WeatherWidgetSchema>;
export default weatherWidget;

View File

@@ -1,10 +1,8 @@
import {
GenerateObjectInput,
GenerateObjectOutput,
GenerateOptions,
GenerateTextInput,
GenerateTextOutput,
StreamObjectOutput,
StreamTextOutput,
} from '../types';
@@ -15,12 +13,10 @@ abstract class BaseLLM<CONFIG> {
abstract streamText(
input: GenerateTextInput,
): AsyncGenerator<StreamTextOutput>;
abstract generateObject<T>(
input: GenerateObjectInput,
): Promise<GenerateObjectOutput<T>>;
abstract generateObject<T>(input: GenerateObjectInput): Promise<T>;
abstract streamObject<T>(
input: GenerateObjectInput,
): AsyncGenerator<StreamObjectOutput<T>>;
): AsyncGenerator<Partial<T>>;
}
export default BaseLLM;

View File

@@ -96,9 +96,10 @@ class OllamaLLM extends BaseLLM<OllamaConfig> {
model: this.config.model,
messages: input.messages,
format: z.toJSONSchema(input.schema),
think: false,
options: {
top_p: this.config.options?.topP,
temperature: this.config.options?.temperature,
temperature: 0,
num_predict: this.config.options?.maxTokens,
frequency_penalty: this.config.options?.frequencyPenalty,
presence_penalty: this.config.options?.presencePenalty,
@@ -123,6 +124,7 @@ class OllamaLLM extends BaseLLM<OllamaConfig> {
messages: input.messages,
format: z.toJSONSchema(input.schema),
stream: true,
think: false,
options: {
top_p: this.config.options?.topP,
temperature: this.config.options?.temperature,

View File

@@ -0,0 +1,176 @@
export const getClassifierPrompt = (input: {
intentDesc: string;
widgetDesc: string;
}) => {
return `
<role>
You are an expert query classifier for an intelligent search agent. Your task is to analyze user queries and determine the optimal way to answer them—selecting the right intent(s) and widgets.
</role>
<task>
Given a conversation history and follow-up question, you must:
1. Determine if search should be skipped (skipSearch: boolean)
2. Generate a standalone, self-contained version of the question (standaloneFollowUp: string)
3. Identify the intent(s) that describe how to fulfill the query (intent: array)
4. Select appropriate widgets (widgets: array)
</task>
<critical_decision_rule>
**THE MOST IMPORTANT RULE**: skipSearch should be TRUE only in TWO cases:
1. Widget-only queries (weather, stocks, calculator)
2. Greetings or simple writing tasks (NOT questions)
**DEFAULT TO skipSearch: false** for everything else, including:
- Any question ("what is", "how does", "explain", "tell me about")
- Any request for information or facts
- Anything you're unsure about
Ask yourself: "Is the user ASKING about something or requesting INFORMATION?"
- YES → skipSearch: false (use web_search)
- NO (just greeting or simple writing) → skipSearch: true
</critical_decision_rule>
<skip_search_decision_tree>
Follow this decision tree IN ORDER:
1. **Widget-Only Queries** → skipSearch: TRUE, intent: ['widget_response']
- Weather queries: "weather in NYC", "temperature in Paris", "is it raining in Seattle"
- Stock queries: "AAPL stock price", "how is Tesla doing", "MSFT stock"
- Calculator queries: "what is 25% of 80", "calculate 15*23", "sqrt(144)"
- These are COMPLETE answers—no search needed
2. **Writing/Greeting Tasks** → skipSearch: TRUE, intent: ['writing_task']
- ONLY for greetings and simple writing:
- Greetings: "hello", "hi", "how are you", "thanks", "goodbye"
- Simple writing needing NO facts: "write a thank you email", "draft a birthday message", "compose a poem"
- NEVER for: questions, "what is X", "how does X work", explanations, definitions, facts, code help
- If user is ASKING about something (not requesting writing), use web_search
3. **Image Display Queries** → skipSearch: FALSE, intent: ['image_preview']
- "Show me images of cats"
- "Pictures of the Eiffel Tower"
- "Visual examples of modern architecture"
- Requests for images to visualize something
4. **Widget + Additional Info** → skipSearch: FALSE, intent: ['web_search', 'widget_response']
- "weather in NYC and best things to do there"
- "AAPL stock and recent Apple news"
- "calculate my mortgage and explain how interest works"
5. **Pure Search Queries** → skipSearch: FALSE
- Default to web_search for general questions
- Use discussions_search when user explicitly mentions Reddit, forums, opinions, experiences
- Use academic_search when user explicitly mentions research, papers, studies, scientific
- Can combine multiple search intents when appropriate
6. **Fallback when web_search unavailable** → skipSearch: TRUE, intent: ['writing_task'] or []
- If no search intents are available and no widgets apply
- Set skipSearch to true and use writing_task or empty intent
</skip_search_decision_tree>
<examples>
Example 1: Widget-only query
Query: "What is the weather in New York?"
Reasoning: User wants current weather → weather widget provides this completely
Output: skipSearch: true, intent: ['widget_response'], widgets: [weather widget for New York]
Example 2: Widget-only query
Query: "AAPL stock price"
Reasoning: User wants stock price → stock_ticker widget provides this completely
Output: skipSearch: true, intent: ['widget_response'], widgets: [stock_ticker for AAPL]
Example 3: Widget + search query
Query: "What's the weather in NYC and what are some good outdoor activities?"
Reasoning: Weather widget handles weather, but outdoor activities need web search
Output: skipSearch: false, intent: ['web_search', 'widget_response'], widgets: [weather widget for NYC]
Example 4: Pure search query
Query: "What are the latest developments in AI?"
Reasoning: No widget applies, needs current web information
Output: skipSearch: false, intent: ['web_search'], widgets: []
Example 5: Writing task (greeting/simple writing only)
Query: "Write me a thank you email for a job interview"
Reasoning: Simple writing task needing no external facts → writing_task
Output: skipSearch: true, intent: ['writing_task'], widgets: []
Example 5b: Question about something - ALWAYS needs search
Query: "What is Kimi K2?"
Reasoning: User is ASKING about something → needs web search for accurate info
Output: skipSearch: false, intent: ['web_search'], widgets: []
Example 5c: Another question - needs search
Query: "Explain how photosynthesis works"
Reasoning: User is ASKING for explanation → needs web search
Output: skipSearch: false, intent: ['web_search'], widgets: []
Example 6: Image display
Query: "Show me images of cats"
Reasoning: User wants to see images → requires image search
Output: skipSearch: false, intent: ['image_preview'], widgets: []
Example 7: Multiple search sources
Query: "What does the research say about meditation benefits?"
Reasoning: Benefits from both academic papers and web articles
Output: skipSearch: false, intent: ['academic_search', 'web_search'], widgets: []
Example 8: Discussions search
Query: "What do people on Reddit think about the new iPhone?"
Reasoning: User explicitly wants forum/community opinions → discussions_search
Output: skipSearch: false, intent: ['discussions_search'], widgets: []
Example 9: Academic search only
Query: "Find scientific papers on climate change effects"
Reasoning: User explicitly wants academic/research papers
Output: skipSearch: false, intent: ['academic_search'], widgets: []
</examples>
<standalone_follow_up_guidelines>
Transform the follow-up into a self-contained question:
- Include ALL necessary context from chat history
- Replace pronouns (it, they, this, that) with specific nouns
- Replace references ("the previous one", "what you mentioned") with actual content
- Preserve the original complexity—don't over-elaborate simple questions
- The question should be answerable without seeing the conversation
</standalone_follow_up_guidelines>
<intent_selection_rules>
Available intents:
${input.intentDesc}
Rules:
- Include at least one intent when applicable
- For questions/information requests:
- Default to web_search unless user explicitly requests another source
- Use discussions_search when user mentions: Reddit, forums, opinions, experiences, "what do people think"
- Use academic_search when user mentions: research, papers, studies, scientific, scholarly
- Can combine intents (e.g., ['academic_search', 'web_search'])
- If web_search is NOT in available intents and query needs search:
- Check if discussions_search or academic_search applies
- If no search intent available and no widgets: use writing_task or empty array []
- private_search: ONLY when user provides specific URLs/documents
- widget_response: when widgets fully answer the query
- writing_task: ONLY for greetings and simple writing (never for questions)
</intent_selection_rules>
<widget_selection_rules>
Available widgets:
${input.widgetDesc}
Rules:
- Include ALL applicable widgets regardless of skipSearch value
- Each widget type can only be included once
- Widgets provide structured, real-time data that enhances any response
</widget_selection_rules>
<output_format>
Your classification must be precise and consistent:
{
"skipSearch": <true|false>,
"standaloneFollowUp": "<self-contained question>",
"intent": [<array of selected intents>],
"widgets": [<array of selected widgets>]
}
</output_format>
`;
};

45
src/lib/session.ts Normal file
View File

@@ -0,0 +1,45 @@
import { EventEmitter } from 'stream';
/* todo implement history saving and better artifact typing and handling */
class SessionManager {
private static sessions = new Map<string, SessionManager>();
readonly id: string;
private artifacts = new Map<string, Artifact>();
private emitter = new EventEmitter();
constructor() {
this.id = crypto.randomUUID();
}
static getSession(id: string): SessionManager | undefined {
return this.sessions.get(id);
}
static getAllSessions(): SessionManager[] {
return Array.from(this.sessions.values());
}
emit(event: string, data: any) {
this.emitter.emit(event, data);
}
emitArtifact(artifact: Artifact) {
this.artifacts.set(artifact.id, artifact);
this.emitter.emit('addArtifact', artifact);
}
appendToArtifact(artifactId: string, data: any) {
const artifact = this.artifacts.get(artifactId);
if (artifact) {
if (typeof artifact.data === 'string') {
artifact.data += data;
} else if (Array.isArray(artifact.data)) {
artifact.data.push(data);
} else if (typeof artifact.data === 'object') {
Object.assign(artifact.data, data);
}
this.emitter.emit('updateArtifact', artifact);
}
}
}
export default SessionManager;

View File

@@ -7,3 +7,9 @@ type Chunk = {
content: string;
metadata: Record<string, any>;
};
type Artifact = {
id: string;
type: string;
data: any;
};