feat(dashboard): add date difference and timezone conversion tools for dashboard

This commit is contained in:
Willie Zutz 2025-07-21 23:49:09 -06:00
parent 3d6aa983dc
commit 1f78b94243
13 changed files with 1143 additions and 909 deletions

View file

@ -113,7 +113,7 @@ When working on this codebase, you might need to:
- Ask for clarification when requirements are unclear
- Do not add dependencies unless explicitly requested
- Only make changes relevant to the specific task
- Do not create test files or run the application unless requested
- **Do not create test files or run the application unless requested**
- Prioritize existing patterns and architectural decisions
- Use the established component structure and styling patterns

18
package-lock.json generated
View file

@ -37,6 +37,7 @@
"jspdf": "^3.0.1",
"langchain": "^0.3.26",
"lucide-react": "^0.525.0",
"luxon": "^3.7.1",
"mammoth": "^1.9.1",
"markdown-to-jsx": "^7.7.2",
"next": "^15.2.2",
@ -59,6 +60,7 @@
"@types/html-to-text": "^9.0.4",
"@types/jsdom": "^21.1.7",
"@types/jspdf": "^2.0.0",
"@types/luxon": "^3.6.2",
"@types/node": "^20",
"@types/pdf-parse": "^1.1.4",
"@types/react": "^19",
@ -3374,6 +3376,13 @@
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
"license": "MIT"
},
"node_modules/@types/luxon": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
"integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
@ -8846,6 +8855,15 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-RkRWjA926cTvz5rAb1BqyWkKbbjzCGchDUIKMCUvNi17j6f6j8uHGDV82Aqcqtzd+icoYpELmG3ksgGiFNNcNg==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/mammoth": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.9.1.tgz",

View file

@ -41,6 +41,7 @@
"jspdf": "^3.0.1",
"langchain": "^0.3.26",
"lucide-react": "^0.525.0",
"luxon": "^3.7.1",
"mammoth": "^1.9.1",
"markdown-to-jsx": "^7.7.2",
"next": "^15.2.2",
@ -63,6 +64,7 @@
"@types/html-to-text": "^9.0.4",
"@types/jsdom": "^21.1.7",
"@types/jspdf": "^2.0.0",
"@types/luxon": "^3.6.2",
"@types/node": "^20",
"@types/pdf-parse": "^1.1.4",
"@types/react": "^19",

View file

@ -3,7 +3,7 @@ import { getWebContent, getWebContentLite } from '@/lib/utils/documents';
import { Document } from '@langchain/core/documents';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { getAvailableChatModelProviders } from '@/lib/providers';
import {
getCustomOpenaiApiKey,
@ -11,6 +11,8 @@ import {
getCustomOpenaiModelName,
} from '@/lib/config';
import { ChatOllama } from '@langchain/ollama';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { timezoneConverterTool, dateDifferenceTool } from '@/lib/tools';
import axios from 'axios';
interface Source {
@ -122,7 +124,7 @@ async function getLLMInstance(
}
}
// Helper function to process the prompt with LLM
// Helper function to process the prompt with LLM using agentic workflow
async function processWithLLM(
prompt: string,
provider: string,
@ -134,10 +136,30 @@ async function processWithLLM(
throw new Error(`Invalid or unavailable model: ${provider}/${model}`);
}
const message = new HumanMessage({ content: prompt });
const response = await llm.invoke([message]);
const tools = [
timezoneConverterTool,
dateDifferenceTool,
];
return response.content as string;
// Create the React agent with tools
const agent = createReactAgent({
llm,
tools,
});
// Invoke the agent with the prompt
const response = await agent.invoke({
messages: [
//new SystemMessage({ content: `You have the following tools available: ${tools.map(tool => tool.name).join(', ')} use them as necessary to complete the task.` }),
new HumanMessage({ content: prompt })
],
},{
recursionLimit: 15, // Limit recursion depth to prevent infinite loops
});
// Extract the final response content
const lastMessage = response.messages[response.messages.length - 1];
return lastMessage.content as string;
}
export async function POST(request: NextRequest) {
@ -145,51 +167,48 @@ export async function POST(request: NextRequest) {
const body: WidgetProcessRequest = await request.json();
// Validate required fields
if (!body.sources || !body.prompt || !body.provider || !body.model) {
if (!body.prompt || !body.provider || !body.model) {
return NextResponse.json(
{ error: 'Missing required fields: sources, prompt, provider, model' },
{ error: 'Missing required fields: prompt, provider, model' },
{ status: 400 },
);
}
// Validate sources
if (!Array.isArray(body.sources) || body.sources.length === 0) {
return NextResponse.json(
{ error: 'At least one source URL is required' },
{ status: 400 },
);
}
const sources = body.sources;
let sourceContents: string[] = [];
let fetchErrors: string[] = [];
let processedPrompt = body.prompt;
let sourcesFetched = 0;
let totalSources = sources ? sources.length : 0;
if (sources && sources.length > 0) {
// Fetch content from all sources
console.log(`Processing widget with ${body.sources.length} source(s)`);
console.log(`Processing widget with ${sources.length} source(s)`);
const sourceResults = await Promise.all(
body.sources.map((source) => fetchSourceContent(source)),
sources.map((source) => fetchSourceContent(source)),
);
// Check for fetch errors
const fetchErrors = sourceResults
fetchErrors = sourceResults
.map((result, index) =>
result.error ? `Source ${index + 1}: ${result.error}` : null,
)
.filter(Boolean);
.filter((msg): msg is string => Boolean(msg));
if (fetchErrors.length > 0) {
console.warn('Some sources failed to fetch:', fetchErrors);
}
// Extract successful content
const sourceContents = sourceResults.map((result) => result.content);
sourceContents = sourceResults.map((result) => result.content);
sourcesFetched = sourceContents.filter((content) => content).length;
// If all sources failed, return error
if (sourceContents.every((content) => !content)) {
if (sourceContents.length > 0 && sourceContents.every((content) => !content)) {
return NextResponse.json(
{ error: 'Failed to fetch content from all sources' },
{ status: 500 },
);
}
// Replace variables in prompt
const processedPrompt = replacePromptVariables(body.prompt, sourceContents);
processedPrompt = replacePromptVariables(body.prompt, sourceContents);
}
console.log('Processing prompt:', processedPrompt);
@ -205,8 +224,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
content: llmResponse,
success: true,
sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length,
sourcesFetched,
totalSources,
warnings: fetchErrors.length > 0 ? fetchErrors : undefined,
});
} catch (llmError) {
@ -221,7 +240,7 @@ export async function POST(request: NextRequest) {
${processedPrompt}
## Sources Successfully Fetched
${sourceContents.filter((content) => content).length} of ${body.sources.length} sources
${sourcesFetched} of ${totalSources} sources
${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`;
@ -232,8 +251,8 @@ ${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`;
llmError instanceof Error
? llmError.message
: 'LLM processing failed',
sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length,
sourcesFetched,
totalSources,
});
}
} catch (error) {

View file

@ -8,7 +8,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
return (
<main className="lg:pl-20 bg-light-primary dark:bg-dark-primary min-h-screen">
<div className={isDashboard ? "mx-4" : "max-w-screen-lg lg:mx-auto mx-4"}>
<div className={isDashboard ? 'mx-4' : 'max-w-screen-lg lg:mx-auto mx-4'}>
{children}
</div>
</main>

View file

@ -6,7 +6,10 @@ import { CheckCheck, Copy as CopyIcon, Brain } from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark, oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useTheme } from 'next-themes';
import ThinkBox from './ThinkBox';
@ -165,7 +168,7 @@ const MarkdownRenderer = ({
},
a: {
component: (props) => (
<a {...props} target='_blank' rel='noopener noreferrer' />
<a {...props} target="_blank" rel="noopener noreferrer" />
),
},
// Prevent rendering of certain HTML elements for security

View file

@ -136,7 +136,13 @@ const WidgetConfigModal = ({
return; // TODO: Add proper validation feedback
}
onSave(config);
// Filter out sources with empty or whitespace-only URLs
const filteredConfig = {
...config,
sources: config.sources.filter((s) => s.url.trim()),
};
onSave(filteredConfig);
handleClose();
};
@ -151,16 +157,6 @@ const WidgetConfigModal = ({
return;
}
if (
config.sources.length === 0 ||
config.sources.every((s) => !s.url.trim())
) {
setPreviewContent(
'Please add at least one source URL before running preview.',
);
return;
}
setIsPreviewLoading(true);
try {
// Replace date/time variables on the client side
@ -233,7 +229,7 @@ const WidgetConfigModal = ({
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
<div className="fixed inset-0 bg-black bg-opacity-75" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
@ -247,7 +243,7 @@ const WidgetConfigModal = ({
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-4xl transform overflow-hidden rounded-2xl bg-light-primary dark:bg-dark-primary p-6 text-left align-middle shadow-xl transition-all">
<DialogPanel className="w-full lg:max-w-[85vw] transform overflow-hidden rounded-2xl bg-light-primary dark:bg-dark-primary p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-medium leading-6 text-black dark:text-white flex items-center justify-between"
@ -347,7 +343,7 @@ const WidgetConfigModal = ({
prompt: e.target.value,
}))
}
rows={6}
rows={8}
className="w-full px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Enter your prompt here..."
/>
@ -466,6 +462,12 @@ const WidgetConfigModal = ({
</code>{' '}
- Content from second source
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{source_content_...}}'}
</code>{' '}
- Content from nth source
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{location}}'}
@ -474,6 +476,24 @@ const WidgetConfigModal = ({
</div>
</div>
</div>
<div className="text-xs text-black/70 dark:text-white/70">
<h5 className="font-medium mb-2">Available Tools (Your model must support tool calling):</h5>
<div className="space-y-1">
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'date_difference'}
</code>{' '}
- Get the difference between two dates (Works best with <a className='text-blue-500' href="https://en.wikipedia.org/wiki/ISO_8601" target="_blank" rel="noopener noreferrer">ISO 8601</a> formatted dates)
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'timezone_converter'}
</code>{' '}
- Convert a date from one timezone to another (Works best with <a className='text-blue-500' href="https://en.wikipedia.org/wiki/ISO_8601" target="_blank" rel="noopener noreferrer">ISO 8601</a> formatted dates)
- Expects target timezone in the <a className='text-blue-500' href="https://nodatime.org/TimeZones" target="_blank" rel="noopener noreferrer">IANA</a> format (e.g., 'America/New_York', 'Europe/London', etc.)
</div>
</div>
</div>
</div>
</div>

View file

@ -100,7 +100,7 @@ const WidgetDisplay = ({
</div>
</CardHeader>
<CardContent className="flex-1">
<CardContent className="flex-1 max-h-[50vh] overflow-y-auto">
{widget.isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<RefreshCw size={20} className="animate-spin mr-2" />

View file

@ -0,0 +1,110 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { DateTime, Interval } from 'luxon';
import { parseDate, getDateParseErrorMessage } from '@/lib/utils/dates';
/**
* Tool that calculates the difference between two dates
*/
export const dateDifferenceTool = tool(
({ startDate, endDate }: { startDate: string; endDate: string }): string => {
try {
console.log(`Calculating difference between "${startDate}" and "${endDate}"`);
// Parse the dates using the extracted utility function
const startDateTime = parseDate(startDate);
const endDateTime = parseDate(endDate);
// Check if dates are valid
if (!startDateTime.isValid) {
return getDateParseErrorMessage(startDate, startDateTime, 'start date');
}
if (!endDateTime.isValid) {
return getDateParseErrorMessage(endDate, endDateTime, 'end date');
}
// Create an interval between the two dates for accurate calculations
const interval = Interval.fromDateTimes(startDateTime, endDateTime);
if (!interval.isValid) {
return `Error: Invalid interval between dates. Reason: ${interval.invalidReason}`;
}
// Calculate differences in various units using Luxon's accurate methods
const diffMilliseconds = Math.abs(endDateTime.diff(startDateTime).toMillis());
const diffSeconds = Math.abs(endDateTime.diff(startDateTime, 'seconds').seconds);
const diffMinutes = Math.abs(endDateTime.diff(startDateTime, 'minutes').minutes);
const diffHours = Math.abs(endDateTime.diff(startDateTime, 'hours').hours);
const diffDays = Math.abs(endDateTime.diff(startDateTime, 'days').days);
const diffWeeks = Math.abs(endDateTime.diff(startDateTime, 'weeks').weeks);
const diffMonths = Math.abs(endDateTime.diff(startDateTime, 'months').months);
const diffYears = Math.abs(endDateTime.diff(startDateTime, 'years').years);
// Get multi-unit breakdown for more human-readable output
const multiUnitDiff = endDateTime.diff(startDateTime, ['years', 'months', 'days', 'hours', 'minutes', 'seconds']).toObject();
// Determine which date is earlier
const isStartEarlier = startDateTime <= endDateTime;
const earlierDate = isStartEarlier ? startDateTime : endDateTime;
const laterDate = isStartEarlier ? endDateTime : startDateTime;
// Format the dates for display with ISO format
const formatDate = (dt: DateTime) => {
return `${dt.toLocaleString(DateTime.DATETIME_FULL)} (${dt.toISO()})`;
};
let result = `Date difference calculation:
From: ${formatDate(earlierDate)}
To: ${formatDate(laterDate)}
Human-readable breakdown:`;
// Add human-readable breakdown
if (multiUnitDiff.years && Math.abs(multiUnitDiff.years) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.years))} year${Math.abs(multiUnitDiff.years) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.months && Math.abs(multiUnitDiff.months) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.months))} month${Math.abs(multiUnitDiff.months) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.days && Math.abs(multiUnitDiff.days) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.days))} day${Math.abs(multiUnitDiff.days) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.hours && Math.abs(multiUnitDiff.hours) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.hours))} hour${Math.abs(multiUnitDiff.hours) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.minutes && Math.abs(multiUnitDiff.minutes) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.minutes))} minute${Math.abs(multiUnitDiff.minutes) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.seconds && Math.abs(multiUnitDiff.seconds) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.seconds))} second${Math.abs(multiUnitDiff.seconds) >= 2 ? 's' : ''}`;
}
result += `\n\nTotal measurements:
Total years: ${diffYears.toFixed(2)}
Total months: ${diffMonths.toFixed(2)}
Total weeks: ${diffWeeks.toFixed(2)}
Total days: ${diffDays.toFixed(2)}
Total hours: ${diffHours.toFixed(2)}
Total minutes: ${diffMinutes.toFixed(2)}
Total seconds: ${diffSeconds.toFixed(2)}
Total milliseconds: ${diffMilliseconds}
Direction: Start date is ${isStartEarlier ? 'earlier than' : 'later than'} the end date.`;
return result;
} catch (error) {
console.error('Error during date difference calculation:', error);
return `Error: ${error instanceof Error ? error.message : 'Unknown error occurred during date difference calculation'}`;
}
},
{
name: 'date_difference',
description: 'Calculate the time difference between two dates. Returns a detailed breakdown of years, months, days, hours, etc. If no timezone is specified, dates will be treated as local to the server time.',
schema: z.object({
startDate: z.string().describe('The start date (e.g., "2024-01-15", "Jan 15, 2024", "2024-01-15 14:30:00Z", "2024-01-15T14:30:00-05:00")'),
endDate: z.string().describe('The end date (e.g., "2024-12-25", "Dec 25, 2024", "2024-12-25 18:00:00Z", "2024-12-25T18:00:00-05:00")'),
}),
}
);

2
src/lib/tools/index.ts Normal file
View file

@ -0,0 +1,2 @@
export { timezoneConverterTool } from './timezoneConverter';
export { dateDifferenceTool } from './dateDifference';

View file

@ -0,0 +1,57 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { DateTime } from 'luxon';
import { parseDate, getDateParseErrorMessage } from '@/lib/utils/dates';
/**
* Tool that converts a date from one timezone to another
*/
export const timezoneConverterTool = tool(
({ dateString, toTimezone }: {
dateString: string;
toTimezone: string;
}): string => {
try {
console.log(`Converting date "${dateString}" to timezone "${toTimezone}"`);
// Parse the date string using the extracted utility function
const dateTime = parseDate(dateString);
// Check if the parsed date is valid
if (!dateTime.isValid) {
return getDateParseErrorMessage(dateString, dateTime);
}
// Convert to target timezone
const targetDateTime = dateTime.setZone(toTimezone);
// Check if the target timezone is valid
if (!targetDateTime.isValid) {
return `Error: Invalid timezone "${toTimezone}". Please use valid timezone identifiers like "America/New_York", "Europe/London", "Asia/Tokyo", etc. Reason: ${targetDateTime.invalidReason}`;
}
// Format both dates as ISO 8601 with timezone offset
const sourceISO = dateTime.toISO();
const targetISO = targetDateTime.toISO();
const output = `Date conversion result:
Source: ${sourceISO} (${dateTime.zoneName})
Target: ${targetISO} (${targetDateTime.zoneName})`;
console.log(output);
return output;
} catch (error) {
console.error('Error during timezone conversion:', error);
return `Error: ${error instanceof Error ? error.message : 'Unknown error occurred during timezone conversion'}`;
}
},
{
name: 'timezone_converter',
description: 'Convert a date from one timezone to another timezone. Supports standard timezone identifiers.',
schema: z.object({
dateString: z.string().describe('The date string to convert. This must include the timezone offset or Z for UTC (e.g., "2023-10-01T00:00:00Z" or "2025-08-10T00:00:00-06:00")'),
toTimezone: z.string().describe('Target timezone to convert to (e.g., "Asia/Tokyo", "America/Los_Angeles", "Europe/Paris")'),
}),
}
);

52
src/lib/utils/dates.ts Normal file
View file

@ -0,0 +1,52 @@
import { DateTime } from 'luxon';
/**
* Parses a date string using multiple Luxon formats with fallback to JavaScript Date parsing.
* Preserves timezone information when available using setZone: true.
*
* @param dateString - The date string to parse
* @returns A parsed DateTime object
*/
export function parseDate(dateString: string): DateTime {
// Try to parse as ISO format first (most common)
let dateTime = DateTime.fromISO(dateString, { setZone: true });
// If ISO parsing fails, try other common formats
if (!dateTime.isValid) {
dateTime = DateTime.fromRFC2822(dateString, { setZone: true });
}
if (!dateTime.isValid) {
dateTime = DateTime.fromHTTP(dateString, { setZone: true });
}
if (!dateTime.isValid) {
dateTime = DateTime.fromSQL(dateString, { setZone: true });
}
// If all parsing attempts fail, try JavaScript Date parsing as fallback
if (!dateTime.isValid) {
const jsDate = new Date(dateString);
if (!isNaN(jsDate.getTime())) {
dateTime = DateTime.fromJSDate(jsDate);
}
}
return dateTime;
}
/**
* Generates a standardized error message for date parsing failures.
*
* @param dateString - The original date string that failed to parse
* @param dateTime - The invalid DateTime object
* @param fieldName - Optional field name for more specific error messages (e.g., "start date", "end date")
* @returns A formatted error message
*/
export function getDateParseErrorMessage(
dateString: string,
dateTime: DateTime,
fieldName: string = 'date'
): string {
return `Error: Unable to parse ${fieldName} "${dateString}". Please provide a valid date format (ISO 8601, RFC 2822, SQL, or common date formats). Reason: ${dateTime.invalidReason}`;
}

1633
yarn.lock

File diff suppressed because it is too large Load diff