Compare commits

...

8 Commits

Author SHA1 Message Date
ItzCrazyKns
6016090f12 feat(actions): stream results internally 2025-12-08 13:10:11 +05:30
ItzCrazyKns
8aed9518a2 feat(researcher): pass research block id 2025-12-08 13:09:52 +05:30
ItzCrazyKns
2df6250ba1 feat(weather): respect unit preference 2025-12-08 13:09:21 +05:30
ItzCrazyKns
85f6c3b901 feat(client-registry): add getMeasurementUnit 2025-12-08 13:08:52 +05:30
ItzCrazyKns
96001a9e26 feat(assistant-steps): handle reading, search_results 2025-12-08 13:08:26 +05:30
ItzCrazyKns
331387efa4 feat(search): add better context handling 2025-12-08 13:07:52 +05:30
ItzCrazyKns
d0e71e6482 feat(types): add search_results research block 2025-12-08 13:07:16 +05:30
ItzCrazyKns
e329820bc8 feat(package): update lucide-react, framer-motion 2025-12-08 13:06:58 +05:30
12 changed files with 294 additions and 126 deletions

View File

@@ -30,12 +30,12 @@
"better-sqlite3": "^11.9.1",
"clsx": "^2.1.0",
"drizzle-orm": "^0.40.1",
"framer-motion": "^12.23.24",
"framer-motion": "^12.23.25",
"html-to-text": "^9.0.5",
"jspdf": "^3.0.1",
"langchain": "^1.0.4",
"lightweight-charts": "^5.0.9",
"lucide-react": "^0.363.0",
"lucide-react": "^0.556.0",
"mammoth": "^1.9.1",
"markdown-to-jsx": "^7.7.2",
"mathjs": "^15.1.0",

View File

@@ -1,6 +1,13 @@
'use client';
import { Brain, Search, FileText, ChevronDown, ChevronUp } from 'lucide-react';
import {
Brain,
Search,
FileText,
ChevronDown,
ChevronUp,
BookSearch,
} from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { useEffect, useState } from 'react';
import { ResearchBlock, ResearchBlockSubStep } from '@/lib/types';
@@ -11,9 +18,12 @@ const getStepIcon = (step: ResearchBlockSubStep) => {
return <Brain className="w-4 h-4" />;
} else if (step.type === 'searching') {
return <Search className="w-4 h-4" />;
} else if (step.type === 'reading') {
} else if (step.type === 'search_results') {
return <FileText className="w-4 h-4" />;
} else if (step.type === 'reading') {
return <BookSearch className="w-4 h-4" />;
}
return null;
};
@@ -25,9 +35,12 @@ const getStepTitle = (
return isStreaming && !step.reasoning ? 'Thinking...' : 'Thinking';
} else if (step.type === 'searching') {
return `Searching ${step.searching.length} ${step.searching.length === 1 ? 'query' : 'queries'}`;
} else if (step.type === 'reading') {
} else if (step.type === 'search_results') {
return `Found ${step.reading.length} ${step.reading.length === 1 ? 'result' : 'results'}`;
} else if (step.type === 'reading') {
return `Reading ${step.reading.length} ${step.reading.length === 1 ? 'source' : 'sources'}`;
}
return 'Processing';
};
@@ -91,10 +104,9 @@ const AssistantSteps = ({
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.2, delay: 0 }}
className="flex gap-3"
className="flex gap-2"
>
{/* Timeline connector */}
<div className="flex flex-col items-center pt-0.5">
<div className="flex flex-col items-center -mt-0.5">
<div
className={`rounded-full p-1.5 bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 ${isStreaming ? 'animate-pulse' : ''}`}
>
@@ -105,7 +117,6 @@ const AssistantSteps = ({
)}
</div>
{/* Step content */}
<div className="flex-1 pb-1">
<span className="text-sm font-medium text-black dark:text-white">
{getStepTitle(step, isStreaming)}
@@ -151,37 +162,39 @@ const AssistantSteps = ({
</div>
)}
{step.type === 'reading' && step.reading.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.reading.slice(0, 4).map((result, idx) => {
const url = result.metadata.url || '';
const title = result.metadata.title || 'Untitled';
const domain = url ? new URL(url).hostname : '';
const faviconUrl = domain
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
: '';
{(step.type === 'search_results' ||
step.type === 'reading') &&
step.reading.length > 0 && (
<div className="flex flex-wrap gap-1.5 mt-1.5">
{step.reading.slice(0, 4).map((result, idx) => {
const url = result.metadata.url || '';
const title = result.metadata.title || 'Untitled';
const domain = url ? new URL(url).hostname : '';
const faviconUrl = domain
? `https://s2.googleusercontent.com/s2/favicons?domain=${domain}&sz=128`
: '';
return (
<span
key={idx}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{faviconUrl && (
<img
src={faviconUrl}
alt=""
className="w-3 h-3 rounded-sm flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<span className="line-clamp-1">{title}</span>
</span>
);
})}
</div>
)}
return (
<span
key={idx}
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md text-xs font-medium bg-light-100 dark:bg-dark-100 text-black/70 dark:text-white/70 border border-light-200 dark:border-dark-200"
>
{faviconUrl && (
<img
src={faviconUrl}
alt=""
className="w-3 h-3 rounded-sm flex-shrink-0"
onError={(e) => {
e.currentTarget.style.display = 'none';
}}
/>
)}
<span className="line-clamp-1">{title}</span>
</span>
);
})}
</div>
)}
</div>
</motion.div>
);

View File

@@ -1,5 +1,6 @@
'use client';
import { getMeasurementUnit } from '@/lib/config/clientRegistry';
import { Wind, Droplets, Gauge } from 'lucide-react';
import { useMemo, useEffect, useState } from 'react';
@@ -226,6 +227,20 @@ const Weather = ({
timezone,
}: WeatherWidgetProps) => {
const [isDarkMode, setIsDarkMode] = useState(false);
const unit = getMeasurementUnit();
const isImperial = unit === 'imperial';
const tempUnitLabel = isImperial ? '°F' : '°C';
const windUnitLabel = isImperial ? 'mph' : 'km/h';
const formatTemp = (celsius: number) => {
if (!Number.isFinite(celsius)) return 0;
return Math.round(isImperial ? (celsius * 9) / 5 + 32 : celsius);
};
const formatWind = (speedKmh: number) => {
if (!Number.isFinite(speedKmh)) return 0;
return Math.round(isImperial ? speedKmh * 0.621371 : speedKmh);
};
useEffect(() => {
const checkDarkMode = () => {
@@ -266,14 +281,12 @@ const Weather = ({
return {
day: dayName,
icon: info.icon,
high: Math.round(daily.temperature_2m_max[idx + 1]),
low: Math.round(daily.temperature_2m_min[idx + 1]),
highF: Math.round((daily.temperature_2m_max[idx + 1] * 9) / 5 + 32),
lowF: Math.round((daily.temperature_2m_min[idx + 1] * 9) / 5 + 32),
high: formatTemp(daily.temperature_2m_max[idx + 1]),
low: formatTemp(daily.temperature_2m_min[idx + 1]),
precipitation: daily.precipitation_probability_max[idx + 1] || 0,
};
});
}, [daily, isDarkMode]);
}, [daily, isDarkMode, isImperial]);
if (!current || !daily || !daily.time || daily.time.length === 0) {
return (
@@ -305,9 +318,9 @@ const Weather = ({
<div>
<div className="flex items-baseline gap-1">
<span className="text-4xl font-bold drop-shadow-md">
{current.temperature_2m}°
{formatTemp(current.temperature_2m)}°
</span>
<span className="text-lg">F C</span>
<span className="text-lg">{tempUnitLabel}</span>
</div>
<p className="text-sm font-medium drop-shadow mt-0.5">
{weatherInfo.description}
@@ -316,7 +329,8 @@ const Weather = ({
</div>
<div className="text-right">
<p className="text-xs font-medium opacity-90">
{daily.temperature_2m_max[0]}° {daily.temperature_2m_min[0]}°
{formatTemp(daily.temperature_2m_max[0])}°{' '}
{formatTemp(daily.temperature_2m_min[0])}°
</p>
</div>
</div>
@@ -370,7 +384,7 @@ const Weather = ({
Wind
</p>
<p className="font-semibold">
{Math.round(current.wind_speed_10m)} km/h
{formatWind(current.wind_speed_10m)} {windUnitLabel}
</p>
</div>
</div>
@@ -394,7 +408,8 @@ const Weather = ({
Feels Like
</p>
<p className="font-semibold">
{Math.round(current.apparent_temperature)}°C
{formatTemp(current.apparent_temperature)}
{tempUnitLabel}
</p>
</div>
</div>

View File

@@ -55,19 +55,20 @@ class SearchAgent {
});
const finalContext =
searchResults?.findings
.filter((f) => f.type === 'search_results')
.flatMap((f) => f.results)
.map((f) => `${f.metadata.title}: ${f.content}`)
searchResults?.searchFindings
.map(
(f, index) =>
`<result index=${index} title=${f.metadata.title}>${f.content}</result>`,
)
.join('\n') || '';
const widgetContext = widgetOutputs
.map((o) => {
return `${o.type}: ${o.llmContext}`;
return `<result>${o.llmContext}</result>`;
})
.join('\n-------------\n');
const finalContextWithWidgets = `<search_results note="These are the search results and you can cite these">${finalContext}</search_results>\n<widgets_result noteForAssistant="Its output is already showed to the user, you can use this information to answer the query but do not CITE this as a souce">${widgetContext}</widgets_result>`;
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);
const answerStream = input.config.llm.streamText({

View File

@@ -50,7 +50,7 @@ class ActionRegistry {
static async execute(
name: string,
params: any,
additionalConfig: AdditionalConfig,
additionalConfig: AdditionalConfig & { researchBlockId: string },
) {
const action = this.actions.get(name);
@@ -63,7 +63,7 @@ class ActionRegistry {
static async executeAll(
actions: ToolCall[],
additionalConfig: AdditionalConfig,
additionalConfig: AdditionalConfig & { researchBlockId: string },
): Promise<ActionOutput[]> {
const results: ActionOutput[] = [];

View File

@@ -1,7 +1,8 @@
import z from 'zod';
import { ResearchAction } from '../../types';
import { Chunk } from '@/lib/types';
import { Chunk, ReadingResearchBlock } from '@/lib/types';
import TurnDown from 'turndown';
import path from 'path';
const turndownService = new TurnDown();
@@ -12,12 +13,19 @@ const schema = z.object({
const scrapeURLAction: ResearchAction<typeof schema> = {
name: 'scrape_url',
description:
'Use after __plan to scrape and extract content from the provided URLs. This is useful when you need detailed information from specific web pages or if the user asks you to summarize or analyze content from certain links. You can scrape maximum of 3 URLs.',
'Use this tool to scrape and extract content from the provided URLs. This is useful when you the user has asked you to extract or summarize information from specific web pages. You can provide up to 3 URLs at a time. NEVER CALL THIS TOOL EXPLICITLY YOURSELF UNLESS INSTRUCTED TO DO SO BY THE USER.',
schema: schema,
enabled: (_) => true,
execute: async (params, additionalConfig) => {
params.urls = params.urls.slice(0, 3);
let readingBlockId = crypto.randomUUID();
let readingEmitted = false;
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
const results: Chunk[] = [];
await Promise.all(
@@ -28,6 +36,70 @@ const scrapeURLAction: ResearchAction<typeof schema> = {
const title =
text.match(/<title>(.*?)<\/title>/i)?.[1] || `Content from ${url}`;
if (
!readingEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
readingEmitted = true;
researchBlock.data.subSteps.push({
id: readingBlockId,
type: 'reading',
reading: [
{
content: '',
metadata: {
url,
title: title,
},
},
],
});
additionalConfig.session.updateBlock(
additionalConfig.researchBlockId,
[
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
],
);
} else if (
readingEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
const subStepIndex = researchBlock.data.subSteps.findIndex(
(step: any) => step.id === readingBlockId,
);
const subStep = researchBlock.data.subSteps[
subStepIndex
] as ReadingResearchBlock;
subStep.reading.push({
content: '',
metadata: {
url,
title: title,
},
});
additionalConfig.session.updateBlock(
additionalConfig.researchBlockId,
[
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
],
);
}
const markdown = turndownService.turndown(text);
results.push({

View File

@@ -1,7 +1,7 @@
import z from 'zod';
import { ResearchAction } from '../../types';
import { searchSearxng } from '@/lib/searxng';
import { Chunk } from '@/lib/types';
import { Chunk, SearchResultsResearchBlock } from '@/lib/types';
const actionSchema = z.object({
type: z.literal('web_search'),
@@ -28,23 +28,90 @@ const webSearchAction: ResearchAction<typeof actionSchema> = {
schema: actionSchema,
enabled: (config) =>
config.classification.classification.skipSearch === false,
execute: async (input, _) => {
execute: async (input, additionalConfig) => {
input.queries = input.queries.slice(0, 3);
const researchBlock = additionalConfig.session.getBlock(
additionalConfig.researchBlockId,
);
if (researchBlock && researchBlock.type === 'research') {
researchBlock.data.subSteps.push({
id: crypto.randomUUID(),
type: 'searching',
searching: input.queries,
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
const searchResultsBlockId = crypto.randomUUID();
let searchResultsEmitted = false;
let results: Chunk[] = [];
const search = async (q: string) => {
const res = await searchSearxng(q);
res.results.forEach((r) => {
results.push({
content: r.content || r.title,
metadata: {
title: r.title,
url: r.url,
},
const resultChunks: Chunk[] = res.results.map((r) => ({
content: r.content || r.title,
metadata: {
title: r.title,
url: r.url,
},
}));
results.push(...resultChunks);
if (
!searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
searchResultsEmitted = true;
researchBlock.data.subSteps.push({
id: searchResultsBlockId,
type: 'search_results',
reading: resultChunks,
});
});
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
} else if (
searchResultsEmitted &&
researchBlock &&
researchBlock.type === 'research'
) {
const subStepIndex = researchBlock.data.subSteps.findIndex(
(step) => step.id === searchResultsBlockId,
);
const subStep = researchBlock.data.subSteps[
subStepIndex
] as SearchResultsResearchBlock;
subStep.reading.push(...resultChunks);
additionalConfig.session.updateBlock(additionalConfig.researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: researchBlock.data.subSteps,
},
]);
}
};
await Promise.all(input.queries.map(search));

View File

@@ -154,33 +154,11 @@ class Researcher {
tool_calls: finalToolCalls,
});
const searchCalls = finalToolCalls.filter(
(tc) =>
tc.name === 'web_search' ||
tc.name === 'academic_search' ||
tc.name === 'discussion_search',
);
if (searchCalls.length > 0 && block && block.type === 'research') {
block.data.subSteps.push({
id: crypto.randomUUID(),
type: 'searching',
searching: searchCalls.map((sc) => sc.arguments.queries).flat(),
});
session.updateBlock(researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: block.data.subSteps,
},
]);
}
const actionResults = await ActionRegistry.executeAll(finalToolCalls, {
llm: input.config.llm,
embedding: input.config.embedding,
session: session,
researchBlockId: researchBlockId,
});
actionOutput.push(...actionResults);
@@ -193,39 +171,41 @@ class Researcher {
content: JSON.stringify(action),
});
});
const searchResults = actionResults.filter(
(a) => a.type === 'search_results',
);
if (searchResults.length > 0 && block && block.type === 'research') {
block.data.subSteps.push({
id: crypto.randomUUID(),
type: 'reading',
reading: searchResults.flatMap((a) => a.results),
});
session.updateBlock(researchBlockId, [
{
op: 'replace',
path: '/data/subSteps',
value: block.data.subSteps,
},
]);
}
}
const searchResults = actionOutput.filter(
(a) => a.type === 'search_results',
);
const searchResults = actionOutput
.filter((a) => a.type === 'search_results')
.flatMap((a) => a.results);
const seenUrls = new Map<string, number>();
const filteredSearchResults = searchResults
.map((result, index) => {
if (result.metadata.url && !seenUrls.has(result.metadata.url)) {
seenUrls.set(result.metadata.url, index);
return result;
} else if (result.metadata.url && seenUrls.has(result.metadata.url)) {
const existingIndex = seenUrls.get(result.metadata.url)!;
const existingResult = searchResults[existingIndex];
existingResult.content += `\n\n${result.content}`;
return undefined;
}
return result;
})
.filter((r) => r !== undefined);
session.emit('data', {
type: 'sources',
data: searchResults.flatMap((a) => a.results),
data: filteredSearchResults,
});
return {
findings: actionOutput,
searchFindings: filteredSearchResults,
};
}
}

View File

@@ -73,6 +73,7 @@ export type ResearcherInput = {
export type ResearcherOutput = {
findings: ActionOutput[];
searchFindings: Chunk[];
};
export type SearchActionOutput = {
@@ -103,6 +104,8 @@ export interface ResearchAction<
enabled: (config: { classification: ClassifierOutput }) => boolean;
execute: (
params: z.infer<TSchema>,
additionalConfig: AdditionalConfig,
additionalConfig: AdditionalConfig & {
researchBlockId: string;
},
) => Promise<ActionOutput>;
}

View File

@@ -17,3 +17,13 @@ export const getShowWeatherWidget = () =>
export const getShowNewsWidget = () =>
getClientConfig('showNewsWidget', 'true') === 'true';
export const getMeasurementUnit = () => {
const value =
getClientConfig('measureUnit') ??
getClientConfig('measurementUnit', 'metric');
if (typeof value !== 'string') return 'metric';
return value.toLowerCase();
};

View File

@@ -75,6 +75,12 @@ export type SearchingResearchBlock = {
searching: string[];
};
export type SearchResultsResearchBlock = {
id: string;
type: 'search_results';
reading: Chunk[];
};
export type ReadingResearchBlock = {
id: string;
type: 'reading';
@@ -84,6 +90,7 @@ export type ReadingResearchBlock = {
export type ResearchBlockSubStep =
| ReasoningResearchBlock
| SearchingResearchBlock
| SearchResultsResearchBlock
| ReadingResearchBlock;
export type ResearchBlock = {

View File

@@ -2830,10 +2830,10 @@ fraction.js@^5.2.1:
resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a"
integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==
framer-motion@^12.23.24:
version "12.23.24"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.23.24.tgz#4895b67e880bd2b1089e61fbaa32ae802fc24b8c"
integrity sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==
framer-motion@^12.23.25:
version "12.23.25"
resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-12.23.25.tgz#32d717f8b172c2673f573c0805ecc37d017441b2"
integrity sha512-gUHGl2e4VG66jOcH0JHhuJQr6ZNwrET9g31ZG0xdXzT0CznP7fHX4P8Bcvuc4MiUB90ysNnWX2ukHRIggkl6hQ==
dependencies:
motion-dom "^12.23.23"
motion-utils "^12.23.6"
@@ -3750,10 +3750,10 @@ lru-cache@^6.0.0:
dependencies:
yallist "^4.0.0"
lucide-react@^0.363.0:
version "0.363.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.363.0.tgz#2bb1f9d09b830dda86f5118fcd097f87247fe0e3"
integrity sha512-AlsfPCsXQyQx7wwsIgzcKOL9LwC498LIMAo+c0Es5PkHJa33xwmYAkkSoKoJWWWSYQEStqu58/jT4tL2gi32uQ==
lucide-react@^0.556.0:
version "0.556.0"
resolved "https://registry.yarnpkg.com/lucide-react/-/lucide-react-0.556.0.tgz#aad61a065737aef30322695a11fd21c7542c71aa"
integrity sha512-iOb8dRk7kLaYBZhR2VlV1CeJGxChBgUthpSP8wom9jfj79qovgG6qcSdiy6vkoREKPnbUYzJsCn4o4PtG3Iy+A==
mammoth@^1.9.1:
version "1.9.1"