feat(dashboard): add date difference and timezone conversion tools for dashboard
This commit is contained in:
parent
3d6aa983dc
commit
1f78b94243
13 changed files with 1143 additions and 909 deletions
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
|
|
@ -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
18
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,52 +167,49 @@ 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 ${sources.length} source(s)`);
|
||||
const sourceResults = await Promise.all(
|
||||
sources.map((source) => fetchSourceContent(source)),
|
||||
);
|
||||
// Check for fetch errors
|
||||
fetchErrors = sourceResults
|
||||
.map((result, index) =>
|
||||
result.error ? `Source ${index + 1}: ${result.error}` : null,
|
||||
)
|
||||
.filter((msg): msg is string => Boolean(msg));
|
||||
if (fetchErrors.length > 0) {
|
||||
console.warn('Some sources failed to fetch:', fetchErrors);
|
||||
}
|
||||
// Extract successful content
|
||||
sourceContents = sourceResults.map((result) => result.content);
|
||||
sourcesFetched = sourceContents.filter((content) => content).length;
|
||||
// If all sources failed, return error
|
||||
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
|
||||
processedPrompt = replacePromptVariables(body.prompt, sourceContents);
|
||||
}
|
||||
|
||||
// Fetch content from all sources
|
||||
console.log(`Processing widget with ${body.sources.length} source(s)`);
|
||||
const sourceResults = await Promise.all(
|
||||
body.sources.map((source) => fetchSourceContent(source)),
|
||||
);
|
||||
|
||||
// Check for fetch errors
|
||||
const fetchErrors = sourceResults
|
||||
.map((result, index) =>
|
||||
result.error ? `Source ${index + 1}: ${result.error}` : null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
if (fetchErrors.length > 0) {
|
||||
console.warn('Some sources failed to fetch:', fetchErrors);
|
||||
}
|
||||
|
||||
// Extract successful content
|
||||
const sourceContents = sourceResults.map((result) => result.content);
|
||||
|
||||
// If all sources failed, return error
|
||||
if (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);
|
||||
|
||||
console.log('Processing prompt:', processedPrompt);
|
||||
|
||||
// Process with LLM
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
@ -51,7 +54,7 @@ const CodeBlock = ({
|
|||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { theme } = useTheme();
|
||||
|
||||
|
||||
// Extract language from className (format could be "language-javascript" or "lang-javascript")
|
||||
let language = '';
|
||||
if (className) {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
110
src/lib/tools/dateDifference.ts
Normal file
110
src/lib/tools/dateDifference.ts
Normal 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
2
src/lib/tools/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { timezoneConverterTool } from './timezoneConverter';
|
||||
export { dateDifferenceTool } from './dateDifference';
|
||||
57
src/lib/tools/timezoneConverter.ts
Normal file
57
src/lib/tools/timezoneConverter.ts
Normal 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
52
src/lib/utils/dates.ts
Normal 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}`;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue