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
|
- Ask for clarification when requirements are unclear
|
||||||
- Do not add dependencies unless explicitly requested
|
- Do not add dependencies unless explicitly requested
|
||||||
- Only make changes relevant to the specific task
|
- 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
|
- Prioritize existing patterns and architectural decisions
|
||||||
- Use the established component structure and styling patterns
|
- 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",
|
"jspdf": "^3.0.1",
|
||||||
"langchain": "^0.3.26",
|
"langchain": "^0.3.26",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
"luxon": "^3.7.1",
|
||||||
"mammoth": "^1.9.1",
|
"mammoth": "^1.9.1",
|
||||||
"markdown-to-jsx": "^7.7.2",
|
"markdown-to-jsx": "^7.7.2",
|
||||||
"next": "^15.2.2",
|
"next": "^15.2.2",
|
||||||
|
|
@ -59,6 +60,7 @@
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/jspdf": "^2.0.0",
|
"@types/jspdf": "^2.0.0",
|
||||||
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pdf-parse": "^1.1.4",
|
"@types/pdf-parse": "^1.1.4",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|
@ -3374,6 +3376,13 @@
|
||||||
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
"integrity": "sha512-MqTGEo5bj5t157U6fA/BiDynNkn0YknVdh48CMPkTSpFTVmvao5UQmm7uEF6xBEo7qIMAlY/JSleYaE6VOdpaA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/ms": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
"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"
|
"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": {
|
"node_modules/mammoth": {
|
||||||
"version": "1.9.1",
|
"version": "1.9.1",
|
||||||
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.9.1.tgz",
|
"resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.9.1.tgz",
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
"jspdf": "^3.0.1",
|
"jspdf": "^3.0.1",
|
||||||
"langchain": "^0.3.26",
|
"langchain": "^0.3.26",
|
||||||
"lucide-react": "^0.525.0",
|
"lucide-react": "^0.525.0",
|
||||||
|
"luxon": "^3.7.1",
|
||||||
"mammoth": "^1.9.1",
|
"mammoth": "^1.9.1",
|
||||||
"markdown-to-jsx": "^7.7.2",
|
"markdown-to-jsx": "^7.7.2",
|
||||||
"next": "^15.2.2",
|
"next": "^15.2.2",
|
||||||
|
|
@ -63,6 +64,7 @@
|
||||||
"@types/html-to-text": "^9.0.4",
|
"@types/html-to-text": "^9.0.4",
|
||||||
"@types/jsdom": "^21.1.7",
|
"@types/jsdom": "^21.1.7",
|
||||||
"@types/jspdf": "^2.0.0",
|
"@types/jspdf": "^2.0.0",
|
||||||
|
"@types/luxon": "^3.6.2",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/pdf-parse": "^1.1.4",
|
"@types/pdf-parse": "^1.1.4",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { getWebContent, getWebContentLite } from '@/lib/utils/documents';
|
||||||
import { Document } from '@langchain/core/documents';
|
import { Document } from '@langchain/core/documents';
|
||||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||||
import { ChatOpenAI } from '@langchain/openai';
|
import { ChatOpenAI } from '@langchain/openai';
|
||||||
import { HumanMessage } from '@langchain/core/messages';
|
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
|
||||||
import { getAvailableChatModelProviders } from '@/lib/providers';
|
import { getAvailableChatModelProviders } from '@/lib/providers';
|
||||||
import {
|
import {
|
||||||
getCustomOpenaiApiKey,
|
getCustomOpenaiApiKey,
|
||||||
|
|
@ -11,6 +11,8 @@ import {
|
||||||
getCustomOpenaiModelName,
|
getCustomOpenaiModelName,
|
||||||
} from '@/lib/config';
|
} from '@/lib/config';
|
||||||
import { ChatOllama } from '@langchain/ollama';
|
import { ChatOllama } from '@langchain/ollama';
|
||||||
|
import { createReactAgent } from '@langchain/langgraph/prebuilt';
|
||||||
|
import { timezoneConverterTool, dateDifferenceTool } from '@/lib/tools';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
interface Source {
|
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(
|
async function processWithLLM(
|
||||||
prompt: string,
|
prompt: string,
|
||||||
provider: string,
|
provider: string,
|
||||||
|
|
@ -134,10 +136,30 @@ async function processWithLLM(
|
||||||
throw new Error(`Invalid or unavailable model: ${provider}/${model}`);
|
throw new Error(`Invalid or unavailable model: ${provider}/${model}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = new HumanMessage({ content: prompt });
|
const tools = [
|
||||||
const response = await llm.invoke([message]);
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
|
|
@ -145,52 +167,49 @@ export async function POST(request: NextRequest) {
|
||||||
const body: WidgetProcessRequest = await request.json();
|
const body: WidgetProcessRequest = await request.json();
|
||||||
|
|
||||||
// Validate required fields
|
// Validate required fields
|
||||||
if (!body.sources || !body.prompt || !body.provider || !body.model) {
|
if (!body.prompt || !body.provider || !body.model) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'Missing required fields: sources, prompt, provider, model' },
|
{ error: 'Missing required fields: prompt, provider, model' },
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate sources
|
const sources = body.sources;
|
||||||
if (!Array.isArray(body.sources) || body.sources.length === 0) {
|
let sourceContents: string[] = [];
|
||||||
return NextResponse.json(
|
let fetchErrors: string[] = [];
|
||||||
{ error: 'At least one source URL is required' },
|
let processedPrompt = body.prompt;
|
||||||
{ status: 400 },
|
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);
|
console.log('Processing prompt:', processedPrompt);
|
||||||
|
|
||||||
// Process with LLM
|
// Process with LLM
|
||||||
|
|
@ -205,8 +224,8 @@ export async function POST(request: NextRequest) {
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
content: llmResponse,
|
content: llmResponse,
|
||||||
success: true,
|
success: true,
|
||||||
sourcesFetched: sourceContents.filter((content) => content).length,
|
sourcesFetched,
|
||||||
totalSources: body.sources.length,
|
totalSources,
|
||||||
warnings: fetchErrors.length > 0 ? fetchErrors : undefined,
|
warnings: fetchErrors.length > 0 ? fetchErrors : undefined,
|
||||||
});
|
});
|
||||||
} catch (llmError) {
|
} catch (llmError) {
|
||||||
|
|
@ -221,7 +240,7 @@ export async function POST(request: NextRequest) {
|
||||||
${processedPrompt}
|
${processedPrompt}
|
||||||
|
|
||||||
## Sources Successfully Fetched
|
## 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')}` : ''}`;
|
${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 instanceof Error
|
||||||
? llmError.message
|
? llmError.message
|
||||||
: 'LLM processing failed',
|
: 'LLM processing failed',
|
||||||
sourcesFetched: sourceContents.filter((content) => content).length,
|
sourcesFetched,
|
||||||
totalSources: body.sources.length,
|
totalSources,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ const Layout = ({ children }: { children: React.ReactNode }) => {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="lg:pl-20 bg-light-primary dark:bg-dark-primary min-h-screen">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ import { CheckCheck, Copy as CopyIcon, Brain } from 'lucide-react';
|
||||||
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
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 { useTheme } from 'next-themes';
|
||||||
import ThinkBox from './ThinkBox';
|
import ThinkBox from './ThinkBox';
|
||||||
|
|
||||||
|
|
@ -51,7 +54,7 @@ const CodeBlock = ({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
|
|
||||||
// Extract language from className (format could be "language-javascript" or "lang-javascript")
|
// Extract language from className (format could be "language-javascript" or "lang-javascript")
|
||||||
let language = '';
|
let language = '';
|
||||||
if (className) {
|
if (className) {
|
||||||
|
|
@ -165,7 +168,7 @@ const MarkdownRenderer = ({
|
||||||
},
|
},
|
||||||
a: {
|
a: {
|
||||||
component: (props) => (
|
component: (props) => (
|
||||||
<a {...props} target='_blank' rel='noopener noreferrer' />
|
<a {...props} target="_blank" rel="noopener noreferrer" />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
// Prevent rendering of certain HTML elements for security
|
// Prevent rendering of certain HTML elements for security
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,13 @@ const WidgetConfigModal = ({
|
||||||
return; // TODO: Add proper validation feedback
|
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();
|
handleClose();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -151,16 +157,6 @@ const WidgetConfigModal = ({
|
||||||
return;
|
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);
|
setIsPreviewLoading(true);
|
||||||
try {
|
try {
|
||||||
// Replace date/time variables on the client side
|
// Replace date/time variables on the client side
|
||||||
|
|
@ -233,7 +229,7 @@ const WidgetConfigModal = ({
|
||||||
leaveFrom="opacity-100"
|
leaveFrom="opacity-100"
|
||||||
leaveTo="opacity-0"
|
leaveTo="opacity-0"
|
||||||
>
|
>
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
<div className="fixed inset-0 bg-black bg-opacity-75" />
|
||||||
</TransitionChild>
|
</TransitionChild>
|
||||||
|
|
||||||
<div className="fixed inset-0 overflow-y-auto">
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
|
@ -247,7 +243,7 @@ const WidgetConfigModal = ({
|
||||||
leaveFrom="opacity-100 scale-100"
|
leaveFrom="opacity-100 scale-100"
|
||||||
leaveTo="opacity-0 scale-95"
|
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
|
<DialogTitle
|
||||||
as="h3"
|
as="h3"
|
||||||
className="text-lg font-medium leading-6 text-black dark:text-white flex items-center justify-between"
|
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,
|
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"
|
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..."
|
placeholder="Enter your prompt here..."
|
||||||
/>
|
/>
|
||||||
|
|
@ -466,6 +462,12 @@ const WidgetConfigModal = ({
|
||||||
</code>{' '}
|
</code>{' '}
|
||||||
- Content from second source
|
- Content from second source
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
|
||||||
|
{'{{source_content_...}}'}
|
||||||
|
</code>{' '}
|
||||||
|
- Content from nth source
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
|
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
|
||||||
{'{{location}}'}
|
{'{{location}}'}
|
||||||
|
|
@ -474,6 +476,24 @@ const WidgetConfigModal = ({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -100,7 +100,7 @@ const WidgetDisplay = ({
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="flex-1">
|
<CardContent className="flex-1 max-h-[50vh] overflow-y-auto">
|
||||||
{widget.isLoading ? (
|
{widget.isLoading ? (
|
||||||
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
<RefreshCw size={20} className="animate-spin mr-2" />
|
<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