fix(refactor): Cleanup components for improved readability and consistency

This commit is contained in:
Willie Zutz 2025-07-19 11:34:56 -06:00
parent 1228beb59a
commit 1b0c2c59b8
10 changed files with 590 additions and 350 deletions

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { getWebContent, getWebContentLite } from '@/lib/utils/documents'; import { getWebContent, getWebContentLite } from '@/lib/utils/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 } from '@langchain/core/messages';
@ -10,6 +11,7 @@ import {
getCustomOpenaiModelName, getCustomOpenaiModelName,
} from '@/lib/config'; } from '@/lib/config';
import { ChatOllama } from '@langchain/ollama'; import { ChatOllama } from '@langchain/ollama';
import axios from 'axios';
interface Source { interface Source {
url: string; url: string;
@ -24,56 +26,71 @@ interface WidgetProcessRequest {
} }
// Helper function to fetch content from a single source // Helper function to fetch content from a single source
async function fetchSourceContent(source: Source): Promise<{ content: string; error?: string }> { async function fetchSourceContent(
source: Source,
): Promise<{ content: string; error?: string }> {
try { try {
let document; let document;
if (source.type === 'Web Page') { if (source.type === 'Web Page') {
// Use headless browser for complex web pages // Use headless browser for complex web pages
document = await getWebContent(source.url); document = await getWebContent(source.url);
} else { } else {
// Use faster fetch for HTTP data/APIs // Use faster fetch for HTTP data/APIs
document = await getWebContentLite(source.url); const response = await axios.get(source.url, { transformResponse: [] });
document = new Document({
pageContent: response.data || '',
metadata: { source: source.url },
});
} }
if (!document) { if (!document) {
return { return {
content: '', content: '',
error: `Failed to fetch content from ${source.url}` error: `Failed to fetch content from ${source.url}`,
}; };
} }
return { content: document.pageContent }; return { content: document.pageContent };
} catch (error) { } catch (error) {
console.error(`Error fetching content from ${source.url}:`, error); console.error(`Error fetching content from ${source.url}:`, error);
return { return {
content: '', content: '',
error: `Error fetching ${source.url}: ${error instanceof Error ? error.message : 'Unknown error'}` error: `Error fetching ${source.url}: ${error instanceof Error ? error.message : 'Unknown error'}`,
}; };
} }
} }
// Helper function to replace variables in prompt // Helper function to replace variables in prompt
function replacePromptVariables(prompt: string, sourceContents: string[], location?: string): string { function replacePromptVariables(
prompt: string,
sourceContents: string[],
location?: string,
): string {
let processedPrompt = prompt; let processedPrompt = prompt;
// Replace source content variables // Replace source content variables
sourceContents.forEach((content, index) => { sourceContents.forEach((content, index) => {
const variable = `{{source_content_${index + 1}}}`; const variable = `{{source_content_${index + 1}}}`;
processedPrompt = processedPrompt.replace(new RegExp(variable, 'g'), content); processedPrompt = processedPrompt.replace(
new RegExp(variable, 'g'),
content,
);
}); });
// Replace location if provided // Replace location if provided
if (location) { if (location) {
processedPrompt = processedPrompt.replace(/\{\{location\}\}/g, location); processedPrompt = processedPrompt.replace(/\{\{location\}\}/g, location);
} }
return processedPrompt; return processedPrompt;
} }
// Helper function to get LLM instance based on provider and model // Helper function to get LLM instance based on provider and model
async function getLLMInstance(provider: string, model: string): Promise<BaseChatModel | null> { async function getLLMInstance(
provider: string,
model: string,
): Promise<BaseChatModel | null> {
try { try {
const chatModelProviders = await getAvailableChatModelProviders(); const chatModelProviders = await getAvailableChatModelProviders();
@ -89,12 +106,12 @@ async function getLLMInstance(provider: string, model: string): Promise<BaseChat
if (chatModelProviders[provider] && chatModelProviders[provider][model]) { if (chatModelProviders[provider] && chatModelProviders[provider][model]) {
const llm = chatModelProviders[provider][model].model as BaseChatModel; const llm = chatModelProviders[provider][model].model as BaseChatModel;
// Special handling for Ollama models // Special handling for Ollama models
if (llm instanceof ChatOllama && provider === 'ollama') { if (llm instanceof ChatOllama && provider === 'ollama') {
llm.numCtx = 2048; // Default context window llm.numCtx = 2048; // Default context window
} }
return llm; return llm;
} }
@ -106,28 +123,32 @@ async function getLLMInstance(provider: string, model: string): Promise<BaseChat
} }
// Helper function to process the prompt with LLM // Helper function to process the prompt with LLM
async function processWithLLM(prompt: string, provider: string, model: string): Promise<string> { async function processWithLLM(
prompt: string,
provider: string,
model: string,
): Promise<string> {
const llm = await getLLMInstance(provider, model); const llm = await getLLMInstance(provider, model);
if (!llm) { if (!llm) {
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 message = new HumanMessage({ content: prompt });
const response = await llm.invoke([message]); const response = await llm.invoke([message]);
return response.content as string; return response.content as string;
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
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.sources || !body.prompt || !body.provider || !body.model) {
return NextResponse.json( return NextResponse.json(
{ error: 'Missing required fields: sources, prompt, provider, model' }, { error: 'Missing required fields: sources, prompt, provider, model' },
{ status: 400 } { status: 400 },
); );
} }
@ -135,19 +156,21 @@ export async function POST(request: NextRequest) {
if (!Array.isArray(body.sources) || body.sources.length === 0) { if (!Array.isArray(body.sources) || body.sources.length === 0) {
return NextResponse.json( return NextResponse.json(
{ error: 'At least one source URL is required' }, { error: 'At least one source URL is required' },
{ status: 400 } { status: 400 },
); );
} }
// Fetch content from all sources // Fetch content from all sources
console.log(`Processing widget with ${body.sources.length} source(s)`); console.log(`Processing widget with ${body.sources.length} source(s)`);
const sourceResults = await Promise.all( const sourceResults = await Promise.all(
body.sources.map(source => fetchSourceContent(source)) body.sources.map((source) => fetchSourceContent(source)),
); );
// Check for fetch errors // Check for fetch errors
const fetchErrors = sourceResults const fetchErrors = sourceResults
.map((result, index) => result.error ? `Source ${index + 1}: ${result.error}` : null) .map((result, index) =>
result.error ? `Source ${index + 1}: ${result.error}` : null,
)
.filter(Boolean); .filter(Boolean);
if (fetchErrors.length > 0) { if (fetchErrors.length > 0) {
@ -155,35 +178,40 @@ export async function POST(request: NextRequest) {
} }
// Extract successful content // Extract successful content
const sourceContents = sourceResults.map(result => result.content); const sourceContents = sourceResults.map((result) => result.content);
// If all sources failed, return error // If all sources failed, return error
if (sourceContents.every(content => !content)) { if (sourceContents.every((content) => !content)) {
return NextResponse.json( return NextResponse.json(
{ error: 'Failed to fetch content from all sources' }, { error: 'Failed to fetch content from all sources' },
{ status: 500 } { status: 500 },
); );
} }
// Replace variables in prompt // Replace variables in prompt
const processedPrompt = replacePromptVariables(body.prompt, sourceContents); const processedPrompt = replacePromptVariables(body.prompt, sourceContents);
console.log('Processed prompt:', processedPrompt.substring(0, 200) + '...'); console.log('Processing prompt:', processedPrompt);
// Process with LLM // Process with LLM
try { try {
const llmResponse = await processWithLLM(processedPrompt, body.provider, body.model); const llmResponse = await processWithLLM(
processedPrompt,
body.provider,
body.model,
);
console.log('LLM response:', llmResponse);
return NextResponse.json({ return NextResponse.json({
content: llmResponse, content: llmResponse,
success: true, success: true,
sourcesFetched: sourceContents.filter(content => content).length, sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length, totalSources: body.sources.length,
warnings: fetchErrors.length > 0 ? fetchErrors : undefined warnings: fetchErrors.length > 0 ? fetchErrors : undefined,
}); });
} catch (llmError) { } catch (llmError) {
console.error('LLM processing failed:', llmError); console.error('LLM processing failed:', llmError);
// Return diagnostic information if LLM fails // Return diagnostic information if LLM fails
const diagnosticResponse = `# Widget Processing - LLM Error const diagnosticResponse = `# Widget Processing - LLM Error
@ -193,24 +221,26 @@ export async function POST(request: NextRequest) {
${processedPrompt} ${processedPrompt}
## Sources Successfully Fetched ## Sources Successfully Fetched
${sourceContents.filter(content => content).length} of ${body.sources.length} sources ${sourceContents.filter((content) => content).length} of ${body.sources.length} sources
${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`; ${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`;
return NextResponse.json({ return NextResponse.json({
content: diagnosticResponse, content: diagnosticResponse,
success: false, success: false,
error: llmError instanceof Error ? llmError.message : 'LLM processing failed', error:
sourcesFetched: sourceContents.filter(content => content).length, llmError instanceof Error
totalSources: body.sources.length ? llmError.message
: 'LLM processing failed',
sourcesFetched: sourceContents.filter((content) => content).length,
totalSources: body.sources.length,
}); });
} }
} catch (error) { } catch (error) {
console.error('Error processing widget:', error); console.error('Error processing widget:', error);
return NextResponse.json( return NextResponse.json(
{ error: 'Internal server error' }, { error: 'Internal server error' },
{ status: 500 } { status: 500 },
); );
} }
} }

View file

@ -1,12 +1,28 @@
'use client'; 'use client';
import { Plus, RefreshCw, Download, Upload, LayoutDashboard, Layers, List } from 'lucide-react'; import {
import { useState } from 'react'; Plus,
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; RefreshCw,
Download,
Upload,
LayoutDashboard,
Layers,
List,
} from 'lucide-react';
import { useState, useEffect, useRef } from 'react';
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal'; import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal';
import WidgetDisplay from '@/components/dashboard/WidgetDisplay'; import WidgetDisplay from '@/components/dashboard/WidgetDisplay';
import { useDashboard } from '@/lib/hooks/useDashboard'; import { useDashboard } from '@/lib/hooks/useDashboard';
import { Widget, WidgetConfig } from '@/lib/types'; import { Widget, WidgetConfig } from '@/lib/types';
import { toast } from 'sonner';
const DashboardPage = () => { const DashboardPage = () => {
const { const {
@ -24,7 +40,19 @@ const DashboardPage = () => {
} = useDashboard(); } = useDashboard();
const [showAddModal, setShowAddModal] = useState(false); const [showAddModal, setShowAddModal] = useState(false);
const [editingWidget, setEditingWidget] = useState<Widget | null>(null); const handleAddWidget = () => { const [editingWidget, setEditingWidget] = useState<Widget | null>(null);
const hasAutoRefreshed = useRef(false);
// Auto-refresh stale widgets when dashboard loads (only once)
useEffect(() => {
if (!isLoading && widgets.length > 0 && !hasAutoRefreshed.current) {
hasAutoRefreshed.current = true;
refreshAllWidgets();
}
}, [isLoading, widgets, refreshAllWidgets]);
const handleAddWidget = () => {
setEditingWidget(null); setEditingWidget(null);
setShowAddModal(true); setShowAddModal(true);
}; };
@ -60,18 +88,18 @@ const DashboardPage = () => {
}; };
const handleRefreshAll = () => { const handleRefreshAll = () => {
refreshAllWidgets(); refreshAllWidgets(true);
}; };
const handleExport = async () => { const handleExport = async () => {
try { try {
const configJson = await exportDashboard(); const configJson = await exportDashboard();
await navigator.clipboard.writeText(configJson); await navigator.clipboard.writeText(configJson);
// TODO: Add toast notification for success toast.success('Dashboard configuration copied to clipboard');
console.log('Dashboard configuration copied to clipboard'); console.log('Dashboard configuration copied to clipboard');
} catch (error) { } catch (error) {
console.error('Export failed:', error); console.error('Export failed:', error);
// TODO: Add toast notification for error toast.error('Failed to copy dashboard configuration');
} }
}; };
@ -79,11 +107,11 @@ const DashboardPage = () => {
try { try {
const configJson = await navigator.clipboard.readText(); const configJson = await navigator.clipboard.readText();
await importDashboard(configJson); await importDashboard(configJson);
// TODO: Add toast notification for success toast.success('Dashboard configuration imported successfully');
console.log('Dashboard configuration imported successfully'); console.log('Dashboard configuration imported successfully');
} catch (error) { } catch (error) {
console.error('Import failed:', error); console.error('Import failed:', error);
// TODO: Add toast notification for error toast.error('Failed to import dashboard configuration');
} }
}; };
@ -98,18 +126,20 @@ const DashboardPage = () => {
<CardHeader> <CardHeader>
<CardTitle>Welcome to your Dashboard</CardTitle> <CardTitle>Welcome to your Dashboard</CardTitle>
<CardDescription> <CardDescription>
Create your first widget to get started with personalized information Create your first widget to get started with personalized
information
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
Widgets let you fetch content from any URL and process it with AI to show exactly what you need. Widgets let you fetch content from any URL and process it with AI to
show exactly what you need.
</p> </p>
</CardContent> </CardContent>
<CardFooter className="justify-center"> <CardFooter className="justify-center">
<button <button
onClick={handleAddWidget} onClick={handleAddWidget}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition duration-200 flex items-center space-x-2" className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition duration-200 flex items-center space-x-2"
> >
@ -130,7 +160,7 @@ const DashboardPage = () => {
<LayoutDashboard /> <LayoutDashboard />
<h1 className="text-3xl font-medium p-2">Dashboard</h1> <h1 className="text-3xl font-medium p-2">Dashboard</h1>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<button <button
onClick={handleRefreshAll} onClick={handleRefreshAll}
@ -139,7 +169,7 @@ const DashboardPage = () => {
> >
<RefreshCw size={18} className="text-black dark:text-white" /> <RefreshCw size={18} className="text-black dark:text-white" />
</button> </button>
<button <button
onClick={handleToggleProcessingMode} onClick={handleToggleProcessingMode}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200" className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
@ -151,7 +181,7 @@ const DashboardPage = () => {
<List size={18} className="text-black dark:text-white" /> <List size={18} className="text-black dark:text-white" />
)} )}
</button> </button>
<button <button
onClick={handleExport} onClick={handleExport}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200" className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
@ -159,7 +189,7 @@ const DashboardPage = () => {
> >
<Download size={18} className="text-black dark:text-white" /> <Download size={18} className="text-black dark:text-white" />
</button> </button>
<button <button
onClick={handleImport} onClick={handleImport}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200" className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
@ -167,7 +197,7 @@ const DashboardPage = () => {
> >
<Upload size={18} className="text-black dark:text-white" /> <Upload size={18} className="text-black dark:text-white" />
</button> </button>
<button <button
onClick={handleAddWidget} onClick={handleAddWidget}
className="p-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition duration-200" className="p-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition duration-200"
@ -186,7 +216,9 @@ const DashboardPage = () => {
<div className="flex items-center justify-center py-12"> <div className="flex items-center justify-center py-12">
<div className="text-center"> <div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-500 dark:text-gray-400">Loading dashboard...</p> <p className="text-gray-500 dark:text-gray-400">
Loading dashboard...
</p>
</div> </div>
</div> </div>
) : widgets.length === 0 ? ( ) : widgets.length === 0 ? (

View file

@ -14,12 +14,12 @@ const extractThinkContent = (content: string): string | null => {
const thinkRegex = /<think>([\s\S]*?)<\/think>/g; const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
const matches = content.match(thinkRegex); const matches = content.match(thinkRegex);
if (!matches) return null; if (!matches) return null;
// Extract content between think tags and join if multiple // Extract content between think tags and join if multiple
const extractedContent = matches const extractedContent = matches
.map(match => match.replace(/<\/?think>/g, '')) .map((match) => match.replace(/<\/?think>/g, ''))
.join('\n\n'); .join('\n\n');
// Return null if content is empty or only whitespace // Return null if content is empty or only whitespace
return extractedContent.trim().length === 0 ? null : extractedContent; return extractedContent.trim().length === 0 ? null : extractedContent;
}; };
@ -28,10 +28,10 @@ const removeThinkTags = (content: string): string => {
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim(); return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
}; };
const ThinkTagProcessor = ({ const ThinkTagProcessor = ({
children, children,
isOverlayMode = false isOverlayMode = false,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
isOverlayMode?: boolean; isOverlayMode?: boolean;
}) => { }) => {
@ -111,9 +111,13 @@ interface MarkdownRendererProps {
thinkOverlay?: boolean; thinkOverlay?: boolean;
} }
const MarkdownRenderer = ({ content, className, thinkOverlay = false }: MarkdownRendererProps) => { const MarkdownRenderer = ({
content,
className,
thinkOverlay = false,
}: MarkdownRendererProps) => {
const [showThinkBox, setShowThinkBox] = useState(false); const [showThinkBox, setShowThinkBox] = useState(false);
// Extract think content from the markdown // Extract think content from the markdown
const thinkContent = thinkOverlay ? extractThinkContent(content) : null; const thinkContent = thinkOverlay ? extractThinkContent(content) : null;
const contentWithoutThink = thinkOverlay ? removeThinkTags(content) : content; const contentWithoutThink = thinkOverlay ? removeThinkTags(content) : content;
@ -153,27 +157,27 @@ const MarkdownRenderer = ({ content, className, thinkOverlay = false }: Markdown
{/* Think box when expanded - shows above markdown */} {/* Think box when expanded - shows above markdown */}
{thinkOverlay && thinkContent && showThinkBox && ( {thinkOverlay && thinkContent && showThinkBox && (
<div className="mb-4"> <div className="mb-4">
<ThinkBox <ThinkBox
content={thinkContent} content={thinkContent}
expanded={true} expanded={true}
onToggle={() => setShowThinkBox(false)} onToggle={() => setShowThinkBox(false)}
/> />
</div> </div>
)} )}
<Markdown <Markdown
className={cn( className={cn(
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]', 'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none', 'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0', 'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
'max-w-none break-words text-black dark:text-white', 'break-words text-black dark:text-white',
className className,
)} )}
options={markdownOverrides} options={markdownOverrides}
> >
{thinkOverlay ? contentWithoutThink : content} {thinkOverlay ? contentWithoutThink : content}
</Markdown> </Markdown>
{/* Overlay icon when think box is collapsed */} {/* Overlay icon when think box is collapsed */}
{thinkOverlay && thinkContent && !showThinkBox && ( {thinkOverlay && thinkContent && !showThinkBox && (
<button <button
@ -181,7 +185,10 @@ const MarkdownRenderer = ({ content, className, thinkOverlay = false }: Markdown
className="absolute top-2 right-2 p-2 rounded-lg bg-black/20 dark:bg-white/20 backdrop-blur-sm opacity-30 hover:opacity-100 transition-opacity duration-200 group" className="absolute top-2 right-2 p-2 rounded-lg bg-black/20 dark:bg-white/20 backdrop-blur-sm opacity-30 hover:opacity-100 transition-opacity duration-200 group"
title="Show thinking process" title="Show thinking process"
> >
<Brain size={16} className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" /> <Brain
size={16}
className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
/>
</button> </button>
)} )}
</div> </div>

View file

@ -273,10 +273,7 @@ const MessageTabs = ({
{/* Answer Tab */} {/* Answer Tab */}
{activeTab === 'text' && ( {activeTab === 'text' && (
<div className="flex flex-col space-y-4 animate-fadeIn"> <div className="flex flex-col space-y-4 animate-fadeIn">
<MarkdownRenderer <MarkdownRenderer content={parsedMessage} className="px-4" />
content={parsedMessage}
className="px-4"
/>
{loading && isLast ? null : ( {loading && isLast ? null : (
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4"> <div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4">

View file

@ -1,7 +1,14 @@
'use client'; 'use client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BookOpenText, Home, Search, SquarePen, Settings, LayoutDashboard } from 'lucide-react'; import {
BookOpenText,
Home,
Search,
SquarePen,
Settings,
LayoutDashboard,
} from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation'; import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useState, type ReactNode } from 'react'; import React, { useState, type ReactNode } from 'react';

View file

@ -17,10 +17,11 @@ const ThinkBox = ({ content, expanded, onToggle }: ThinkBoxProps) => {
} }
const [internalExpanded, setInternalExpanded] = useState(false); const [internalExpanded, setInternalExpanded] = useState(false);
// Use external expanded state if provided, otherwise use internal state // Use external expanded state if provided, otherwise use internal state
const isExpanded = expanded !== undefined ? expanded : internalExpanded; const isExpanded = expanded !== undefined ? expanded : internalExpanded;
const handleToggle = onToggle || (() => setInternalExpanded(!internalExpanded)); const handleToggle =
onToggle || (() => setInternalExpanded(!internalExpanded));
return ( return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden"> <div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">

View file

@ -1,6 +1,12 @@
'use client'; 'use client';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react'; import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
} from '@headlessui/react';
import { X, Plus, Trash2, Play, Save } from 'lucide-react'; import { X, Plus, Trash2, Play, Save } from 'lucide-react';
import { Fragment, useState, useEffect } from 'react'; import { Fragment, useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/MarkdownRenderer'; import MarkdownRenderer from '@/components/MarkdownRenderer';
@ -9,20 +15,28 @@ import ModelSelector from '@/components/MessageInputActions/ModelSelector';
// Helper function to replace date/time variables in prompts on the client side // Helper function to replace date/time variables in prompts on the client side
const replaceDateTimeVariables = (prompt: string): string => { const replaceDateTimeVariables = (prompt: string): string => {
let processedPrompt = prompt; let processedPrompt = prompt;
// Replace UTC datetime // Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) { if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString(); const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime); processedPrompt = processedPrompt.replace(
/\{\{current_utc_datetime\}\}/g,
utcDateTime,
);
} }
// Replace local datetime // Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) { if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date(); const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString(); const localDateTime = new Date(
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime); now.getTime() - now.getTimezoneOffset() * 60000,
).toISOString();
processedPrompt = processedPrompt.replace(
/\{\{current_local_datetime\}\}/g,
localDateTime,
);
} }
return processedPrompt; return processedPrompt;
}; };
@ -67,7 +81,10 @@ const WidgetConfigModal = ({
const [previewContent, setPreviewContent] = useState<string>(''); const [previewContent, setPreviewContent] = useState<string>('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false); const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState<{ provider: string; model: string } | null>(null); const [selectedModel, setSelectedModel] = useState<{
provider: string;
model: string;
} | null>(null);
// Update config when editingWidget changes // Update config when editingWidget changes
useEffect(() => { useEffect(() => {
@ -106,7 +123,7 @@ const WidgetConfigModal = ({
// Update config when model selection changes // Update config when model selection changes
useEffect(() => { useEffect(() => {
if (selectedModel) { if (selectedModel) {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
provider: selectedModel.provider, provider: selectedModel.provider,
model: selectedModel.model, model: selectedModel.model,
@ -118,7 +135,7 @@ const WidgetConfigModal = ({
if (!config.title.trim() || !config.prompt.trim()) { if (!config.title.trim() || !config.prompt.trim()) {
return; // TODO: Add proper validation feedback return; // TODO: Add proper validation feedback
} }
onSave(config); onSave(config);
onClose(); onClose();
}; };
@ -129,8 +146,13 @@ const WidgetConfigModal = ({
return; return;
} }
if (config.sources.length === 0 || config.sources.every(s => !s.url.trim())) { if (
setPreviewContent('Please add at least one source URL before running preview.'); config.sources.length === 0 ||
config.sources.every((s) => !s.url.trim())
) {
setPreviewContent(
'Please add at least one source URL before running preview.',
);
return; return;
} }
@ -145,7 +167,7 @@ const WidgetConfigModal = ({
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
sources: config.sources.filter(s => s.url.trim()), // Only send sources with URLs sources: config.sources.filter((s) => s.url.trim()), // Only send sources with URLs
prompt: processedPrompt, prompt: processedPrompt,
provider: config.provider, provider: config.provider,
model: config.model, model: config.model,
@ -153,40 +175,44 @@ const WidgetConfigModal = ({
}); });
const result = await response.json(); const result = await response.json();
if (result.success) { if (result.success) {
setPreviewContent(result.content); setPreviewContent(result.content);
} else { } else {
setPreviewContent(`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`); setPreviewContent(
`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`,
);
} }
} catch (error) { } catch (error) {
console.error('Preview error:', error); console.error('Preview error:', error);
setPreviewContent(`**Network Error:** Failed to connect to the preview service.\n\n${error instanceof Error ? error.message : 'Unknown error'}`); setPreviewContent(
`**Network Error:** Failed to connect to the preview service.\n\n${error instanceof Error ? error.message : 'Unknown error'}`,
);
} finally { } finally {
setIsPreviewLoading(false); setIsPreviewLoading(false);
} }
}; };
const addSource = () => { const addSource = () => {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
sources: [...prev.sources, { url: '', type: 'Web Page' }] sources: [...prev.sources, { url: '', type: 'Web Page' }],
})); }));
}; };
const removeSource = (index: number) => { const removeSource = (index: number) => {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
sources: prev.sources.filter((_, i) => i !== index) sources: prev.sources.filter((_, i) => i !== index),
})); }));
}; };
const updateSource = (index: number, field: keyof Source, value: string) => { const updateSource = (index: number, field: keyof Source, value: string) => {
setConfig(prev => ({ setConfig((prev) => ({
...prev, ...prev,
sources: prev.sources.map((source, i) => sources: prev.sources.map((source, i) =>
i === index ? { ...source, [field]: value } : source i === index ? { ...source, [field]: value } : source,
) ),
})); }));
}; };
@ -241,7 +267,12 @@ const WidgetConfigModal = ({
<input <input
type="text" type="text"
value={config.title} value={config.title}
onChange={(e) => setConfig(prev => ({ ...prev, title: e.target.value }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
title: e.target.value,
}))
}
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 widget title..." placeholder="Enter widget title..."
/> />
@ -258,13 +289,21 @@ const WidgetConfigModal = ({
<input <input
type="url" type="url"
value={source.url} value={source.url}
onChange={(e) => updateSource(index, 'url', e.target.value)} onChange={(e) =>
updateSource(index, 'url', e.target.value)
}
className="flex-1 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="flex-1 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="https://example.com" placeholder="https://example.com"
/> />
<select <select
value={source.type} value={source.type}
onChange={(e) => updateSource(index, 'type', e.target.value as Source['type'])} onChange={(e) =>
updateSource(
index,
'type',
e.target.value as Source['type'],
)
}
className="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="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"
> >
<option value="Web Page">Web Page</option> <option value="Web Page">Web Page</option>
@ -297,7 +336,12 @@ const WidgetConfigModal = ({
</label> </label>
<textarea <textarea
value={config.prompt} value={config.prompt}
onChange={(e) => setConfig(prev => ({ ...prev, prompt: e.target.value }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
prompt: e.target.value,
}))
}
rows={6} rows={6}
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..."
@ -310,13 +354,14 @@ const WidgetConfigModal = ({
Model & Provider Model & Provider
</label> </label>
<ModelSelector <ModelSelector
selectedModel={selectedModel} selectedModel={selectedModel}
setSelectedModel={setSelectedModel} setSelectedModel={setSelectedModel}
truncateModelName={false} truncateModelName={false}
showModelName={true} showModelName={true}
/> />
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Select the AI model and provider to process your widget content Select the AI model and provider to process your widget
content
</p> </p>
</div> </div>
@ -330,12 +375,24 @@ const WidgetConfigModal = ({
type="number" type="number"
min="1" min="1"
value={config.refreshFrequency} value={config.refreshFrequency}
onChange={(e) => setConfig(prev => ({ ...prev, refreshFrequency: parseInt(e.target.value) || 1 }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
refreshFrequency: parseInt(e.target.value) || 1,
}))
}
className="flex-1 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="flex-1 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"
/> />
<select <select
value={config.refreshUnit} value={config.refreshUnit}
onChange={(e) => setConfig(prev => ({ ...prev, refreshUnit: e.target.value as 'minutes' | 'hours' }))} onChange={(e) =>
setConfig((prev) => ({
...prev,
refreshUnit: e.target.value as
| 'minutes'
| 'hours',
}))
}
className="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="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"
> >
<option value="minutes">Minutes</option> <option value="minutes">Minutes</option>
@ -360,11 +417,14 @@ const WidgetConfigModal = ({
{isPreviewLoading ? 'Loading...' : 'Run Preview'} {isPreviewLoading ? 'Loading...' : 'Run Preview'}
</button> </button>
</div> </div>
<div className="h-80 p-4 border border-light-200 dark:border-dark-200 rounded-md bg-light-secondary dark:bg-dark-secondary overflow-y-auto"> <div className="h-80 p-4 border border-light-200 dark:border-dark-200 rounded-md bg-light-secondary dark:bg-dark-secondary overflow-y-auto max-w-full">
{previewContent ? ( {previewContent ? (
<div className="prose prose-sm dark:prose-invert max-w-none"> <div className="prose prose-sm dark:prose-invert max-w-full">
<MarkdownRenderer thinkOverlay={true} content={previewContent} /> <MarkdownRenderer
thinkOverlay={true}
content={previewContent}
/>
</div> </div>
) : ( ) : (
<div className="text-sm text-black/50 dark:text-white/50 italic"> <div className="text-sm text-black/50 dark:text-white/50 italic">
@ -377,11 +437,36 @@ const WidgetConfigModal = ({
<div className="text-xs text-black/70 dark:text-white/70"> <div className="text-xs text-black/70 dark:text-white/70">
<h5 className="font-medium mb-2">Available Variables:</h5> <h5 className="font-medium mb-2">Available Variables:</h5>
<div className="space-y-1"> <div className="space-y-1">
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{current_utc_datetime}}'}</code> - Current UTC date and time</div> <div>
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{current_local_datetime}}'}</code> - Current local date and time</div> <code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{source_content_1}}'}</code> - Content from first source</div> {'{{current_utc_datetime}}'}
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{source_content_2}}'}</code> - Content from second source</div> </code>{' '}
<div><code className="bg-light-200 dark:bg-dark-200 px-1 rounded">{'{{location}}'}</code> - Your current location</div> - Current UTC date and time
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{current_local_datetime}}'}
</code>{' '}
- Current local date and time
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{source_content_1}}'}
</code>{' '}
- Content from first source
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{source_content_2}}'}
</code>{' '}
- Content from second source
</div>
<div>
<code className="bg-light-200 dark:bg-dark-200 px-1 rounded">
{'{{location}}'}
</code>{' '}
- Your current location
</div>
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,6 +1,14 @@
'use client'; 'use client';
import { RefreshCw, Edit, Trash2, Clock, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react'; import {
RefreshCw,
Edit,
Trash2,
Clock,
AlertCircle,
ChevronDown,
ChevronUp,
} from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import MarkdownRenderer from '@/components/MarkdownRenderer'; import MarkdownRenderer from '@/components/MarkdownRenderer';
import { useState } from 'react'; import { useState } from 'react';
@ -32,12 +40,17 @@ interface WidgetDisplayProps {
onRefresh: (widgetId: string) => void; onRefresh: (widgetId: string) => void;
} }
const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayProps) => { const WidgetDisplay = ({
widget,
onEdit,
onDelete,
onRefresh,
}: WidgetDisplayProps) => {
const [isFooterExpanded, setIsFooterExpanded] = useState(false); const [isFooterExpanded, setIsFooterExpanded] = useState(false);
const formatLastUpdated = (date: Date | null) => { const formatLastUpdated = (date: Date | null) => {
if (!date) return 'Never'; if (!date) return 'Never';
const now = new Date(); const now = new Date();
const diffMs = now.getTime() - date.getTime(); const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60)); const diffMins = Math.floor(diffMs / (1000 * 60));
@ -61,16 +74,16 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
<CardTitle className="text-lg font-medium truncate"> <CardTitle className="text-lg font-medium truncate">
{widget.title} {widget.title}
</CardTitle> </CardTitle>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{/* Last updated date with refresh frequency tooltip */} {/* Last updated date with refresh frequency tooltip */}
<span <span
className="text-xs text-gray-500 dark:text-gray-400" className="text-xs text-gray-500 dark:text-gray-400"
title={getRefreshFrequencyText()} title={getRefreshFrequencyText()}
> >
{formatLastUpdated(widget.lastUpdated)} {formatLastUpdated(widget.lastUpdated)}
</span> </span>
{/* Refresh button */} {/* Refresh button */}
<button <button
onClick={() => onRefresh(widget.id)} onClick={() => onRefresh(widget.id)}
@ -78,9 +91,9 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
className="p-1.5 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded transition-colors disabled:opacity-50" className="p-1.5 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded transition-colors disabled:opacity-50"
title="Refresh Widget" title="Refresh Widget"
> >
<RefreshCw <RefreshCw
size={16} size={16}
className={`text-gray-600 dark:text-gray-400 ${widget.isLoading ? 'animate-spin' : ''}`} className={`text-gray-600 dark:text-gray-400 ${widget.isLoading ? 'animate-spin' : ''}`}
/> />
</button> </button>
</div> </div>
@ -95,14 +108,21 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
</div> </div>
) : widget.error ? ( ) : widget.error ? (
<div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800"> <div className="flex items-start space-x-2 p-3 bg-red-50 dark:bg-red-900/20 rounded border border-red-200 dark:border-red-800">
<AlertCircle size={16} className="text-red-500 mt-0.5 flex-shrink-0" /> <AlertCircle
size={16}
className="text-red-500 mt-0.5 flex-shrink-0"
/>
<div className="flex-1"> <div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">Error Loading Content</p> <p className="text-sm font-medium text-red-800 dark:text-red-300">
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{widget.error}</p> Error Loading Content
</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
{widget.error}
</p>
</div> </div>
</div> </div>
) : widget.content ? ( ) : widget.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none"> <div className="prose prose-sm dark:prose-invert">
<MarkdownRenderer content={widget.content} thinkOverlay={true} /> <MarkdownRenderer content={widget.content} thinkOverlay={true} />
</div> </div>
) : ( ) : (
@ -134,10 +154,15 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
{/* Sources */} {/* Sources */}
{widget.sources.length > 0 && ( {widget.sources.length > 0 && (
<div> <div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Sources:</p> <p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Sources:
</p>
<div className="space-y-1"> <div className="space-y-1">
{widget.sources.map((source, index) => ( {widget.sources.map((source, index) => (
<div key={index} className="flex items-center space-x-2 text-xs"> <div
key={index}
className="flex items-center space-x-2 text-xs"
>
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full"></span> <span className="inline-block w-2 h-2 bg-blue-500 rounded-full"></span>
<span className="text-gray-600 dark:text-gray-300 truncate"> <span className="text-gray-600 dark:text-gray-300 truncate">
{source.url} {source.url}
@ -160,7 +185,7 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
<Edit size={12} /> <Edit size={12} />
<span>Edit</span> <span>Edit</span>
</button> </button>
<button <button
onClick={() => onDelete(widget.id)} onClick={() => onDelete(widget.id)}
className="flex items-center space-x-1 px-2 py-1 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors" className="flex items-center space-x-1 px-2 py-1 text-xs text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"

View file

@ -21,7 +21,8 @@ interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode; children: React.ReactNode;
} }
interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> { interface CardDescriptionProps
extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode; children: React.ReactNode;
} }
@ -31,13 +32,13 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
ref={ref} ref={ref}
className={cn( className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm', 'rounded-lg border bg-card text-card-foreground shadow-sm',
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
) ),
); );
Card.displayName = 'Card'; Card.displayName = 'Card';
@ -50,7 +51,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
> >
{children} {children}
</div> </div>
) ),
); );
CardHeader.displayName = 'CardHeader'; CardHeader.displayName = 'CardHeader';
@ -60,27 +61,28 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, CardTitleProps>(
ref={ref} ref={ref}
className={cn( className={cn(
'text-2xl font-semibold leading-none tracking-tight', 'text-2xl font-semibold leading-none tracking-tight',
className className,
)} )}
{...props} {...props}
> >
{children} {children}
</h3> </h3>
) ),
); );
CardTitle.displayName = 'CardTitle'; CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionProps>( const CardDescription = React.forwardRef<
({ className, children, ...props }, ref) => ( HTMLParagraphElement,
<p CardDescriptionProps
ref={ref} >(({ className, children, ...props }, ref) => (
className={cn('text-sm text-muted-foreground', className)} <p
{...props} ref={ref}
> className={cn('text-sm text-muted-foreground', className)}
{children} {...props}
</p> >
) {children}
); </p>
));
CardDescription.displayName = 'CardDescription'; CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>( const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
@ -88,7 +90,7 @@ const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
<div ref={ref} className={cn('p-6 pt-0', className)} {...props}> <div ref={ref} className={cn('p-6 pt-0', className)} {...props}>
{children} {children}
</div> </div>
) ),
); );
CardContent.displayName = 'CardContent'; CardContent.displayName = 'CardContent';
@ -101,8 +103,15 @@ const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
> >
{children} {children}
</div> </div>
) ),
); );
CardFooter.displayName = 'CardFooter'; CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardDescription,
CardContent,
};

View file

@ -1,11 +1,11 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Widget, Widget,
WidgetConfig, WidgetConfig,
DashboardState, DashboardState,
DashboardConfig, DashboardConfig,
DASHBOARD_STORAGE_KEYS, DASHBOARD_STORAGE_KEYS,
WidgetCache WidgetCache,
} from '@/lib/types'; } from '@/lib/types';
// Helper function to request location permission and get user's location // Helper function to request location permission and get user's location
@ -31,7 +31,7 @@ const requestLocationPermission = async (): Promise<string | undefined> => {
enableHighAccuracy: true, enableHighAccuracy: true,
timeout: 10000, // 10 seconds timeout timeout: 10000, // 10 seconds timeout
maximumAge: 300000, // 5 minutes cache maximumAge: 300000, // 5 minutes cache
} },
); );
}); });
} catch (error) { } catch (error) {
@ -43,20 +43,28 @@ const requestLocationPermission = async (): Promise<string | undefined> => {
// Helper function to replace date/time variables in prompts on the client side // Helper function to replace date/time variables in prompts on the client side
const replaceDateTimeVariables = (prompt: string): string => { const replaceDateTimeVariables = (prompt: string): string => {
let processedPrompt = prompt; let processedPrompt = prompt;
// Replace UTC datetime // Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) { if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString(); const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime); processedPrompt = processedPrompt.replace(
/\{\{current_utc_datetime\}\}/g,
utcDateTime,
);
} }
// Replace local datetime // Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) { if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date(); const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString(); const localDateTime = new Date(
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime); now.getTime() - now.getTimezoneOffset() * 60000,
).toISOString();
processedPrompt = processedPrompt.replace(
/\{\{current_local_datetime\}\}/g,
localDateTime,
);
} }
return processedPrompt; return processedPrompt;
}; };
@ -66,19 +74,19 @@ interface UseDashboardReturn {
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
settings: DashboardConfig['settings']; settings: DashboardConfig['settings'];
// Widget management // Widget management
addWidget: (config: WidgetConfig) => void; addWidget: (config: WidgetConfig) => void;
updateWidget: (id: string, config: WidgetConfig) => void; updateWidget: (id: string, config: WidgetConfig) => void;
deleteWidget: (id: string) => void; deleteWidget: (id: string) => void;
refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>; refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>;
refreshAllWidgets: () => Promise<void>; refreshAllWidgets: (forceRefresh?: boolean) => Promise<void>;
// Storage management // Storage management
exportDashboard: () => Promise<string>; exportDashboard: () => Promise<string>;
importDashboard: (configJson: string) => Promise<void>; importDashboard: (configJson: string) => Promise<void>;
clearCache: () => void; clearCache: () => void;
// Settings // Settings
updateSettings: (newSettings: Partial<DashboardConfig['settings']>) => void; updateSettings: (newSettings: Partial<DashboardConfig['settings']>) => void;
} }
@ -103,13 +111,19 @@ export const useDashboard = (): UseDashboardReturn => {
// Save widgets to localStorage whenever they change (but not on initial load) // Save widgets to localStorage whenever they change (but not on initial load)
useEffect(() => { useEffect(() => {
if (!state.isLoading) { if (!state.isLoading) {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.WIDGETS, JSON.stringify(state.widgets)); localStorage.setItem(
DASHBOARD_STORAGE_KEYS.WIDGETS,
JSON.stringify(state.widgets),
);
} }
}, [state.widgets, state.isLoading]); }, [state.widgets, state.isLoading]);
// Save settings to localStorage whenever they change // Save settings to localStorage whenever they change
useEffect(() => { useEffect(() => {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.SETTINGS, JSON.stringify(state.settings)); localStorage.setItem(
DASHBOARD_STORAGE_KEYS.SETTINGS,
JSON.stringify(state.settings),
);
}, [state.settings]); }, [state.settings]);
const loadDashboardData = useCallback(() => { const loadDashboardData = useCallback(() => {
@ -117,23 +131,27 @@ export const useDashboard = (): UseDashboardReturn => {
// Load widgets // Load widgets
const savedWidgets = localStorage.getItem(DASHBOARD_STORAGE_KEYS.WIDGETS); const savedWidgets = localStorage.getItem(DASHBOARD_STORAGE_KEYS.WIDGETS);
const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : []; const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : [];
// Convert date strings back to Date objects // Convert date strings back to Date objects
widgets.forEach(widget => { widgets.forEach((widget) => {
if (widget.lastUpdated) { if (widget.lastUpdated) {
widget.lastUpdated = new Date(widget.lastUpdated); widget.lastUpdated = new Date(widget.lastUpdated);
} }
}); });
// Load settings // Load settings
const savedSettings = localStorage.getItem(DASHBOARD_STORAGE_KEYS.SETTINGS); const savedSettings = localStorage.getItem(
const settings = savedSettings ? JSON.parse(savedSettings) : { DASHBOARD_STORAGE_KEYS.SETTINGS,
parallelLoading: true, );
autoRefresh: false, const settings = savedSettings
theme: 'auto', ? JSON.parse(savedSettings)
}; : {
parallelLoading: true,
autoRefresh: false,
theme: 'auto',
};
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets, widgets,
settings, settings,
@ -141,7 +159,7 @@ export const useDashboard = (): UseDashboardReturn => {
})); }));
} catch (error) { } catch (error) {
console.error('Error loading dashboard data:', error); console.error('Error loading dashboard data:', error);
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
error: 'Failed to load dashboard data', error: 'Failed to load dashboard data',
isLoading: false, isLoading: false,
@ -159,27 +177,27 @@ export const useDashboard = (): UseDashboardReturn => {
error: null, error: null,
}; };
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets: [...prev.widgets, newWidget], widgets: [...prev.widgets, newWidget],
})); }));
}, []); }, []);
const updateWidget = useCallback((id: string, config: WidgetConfig) => { const updateWidget = useCallback((id: string, config: WidgetConfig) => {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets: prev.widgets.map(widget => widgets: prev.widgets.map((widget) =>
widget.id === id widget.id === id
? { ...widget, ...config, id } // Preserve the ID ? { ...widget, ...config, id } // Preserve the ID
: widget : widget,
), ),
})); }));
}, []); }, []);
const deleteWidget = useCallback((id: string) => { const deleteWidget = useCallback((id: string) => {
setState(prev => ({ setState((prev) => ({
...prev, ...prev,
widgets: prev.widgets.filter(widget => widget.id !== id), widgets: prev.widgets.filter((widget) => widget.id !== id),
})); }));
// Also remove from cache // Also remove from cache
@ -200,143 +218,160 @@ export const useDashboard = (): UseDashboardReturn => {
const isWidgetCacheValid = (widget: Widget): boolean => { const isWidgetCacheValid = (widget: Widget): boolean => {
const cache = getWidgetCache(); const cache = getWidgetCache();
const cachedData = cache[widget.id]; const cachedData = cache[widget.id];
if (!cachedData) return false; if (!cachedData) return false;
const now = new Date(); const now = new Date();
const expiresAt = new Date(cachedData.expiresAt); const expiresAt = new Date(cachedData.expiresAt);
return now < expiresAt; return now < expiresAt;
}; };
const getCacheExpiryTime = (widget: Widget): Date => { const getCacheExpiryTime = (widget: Widget): Date => {
const now = new Date(); const now = new Date();
const refreshMs = widget.refreshFrequency * (widget.refreshUnit === 'hours' ? 3600000 : 60000); const refreshMs =
widget.refreshFrequency *
(widget.refreshUnit === 'hours' ? 3600000 : 60000);
return new Date(now.getTime() + refreshMs); return new Date(now.getTime() + refreshMs);
}; };
const refreshWidget = useCallback(async (id: string, forceRefresh: boolean = false) => { const refreshWidget = useCallback(
const widget = state.widgets.find(w => w.id === id); async (id: string, forceRefresh: boolean = false) => {
if (!widget) return; const widget = state.widgets.find((w) => w.id === id);
if (!widget) return;
// Check cache first (unless forcing refresh) // Check cache first (unless forcing refresh)
if (!forceRefresh && isWidgetCacheValid(widget)) { if (!forceRefresh && isWidgetCacheValid(widget)) {
const cache = getWidgetCache();
const cachedData = cache[widget.id];
setState(prev => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id
? { ...w, content: cachedData.content, lastUpdated: new Date(cachedData.lastFetched) }
: w
),
}));
return;
}
// Set loading state
setState(prev => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id ? { ...w, isLoading: true, error: null } : w
),
}));
try {
// Check if prompt uses location variable and request permission if needed
let location: string | undefined;
if (widget.prompt.includes('{{location}}')) {
location = await requestLocationPermission();
}
// Replace date/time variables on the client side
const processedPrompt = replaceDateTimeVariables(widget.prompt);
const response = await fetch('/api/dashboard/process-widget', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: widget.sources,
prompt: processedPrompt,
provider: widget.provider,
model: widget.model,
location,
}),
});
const result = await response.json();
const now = new Date();
if (result.success) {
// Update widget
setState(prev => ({
...prev,
widgets: prev.widgets.map(w =>
w.id === id
? {
...w,
isLoading: false,
content: result.content,
lastUpdated: now,
error: null,
}
: w
),
}));
// Cache the result
const cache = getWidgetCache(); const cache = getWidgetCache();
cache[id] = { const cachedData = cache[widget.id];
content: result.content, setState((prev) => ({
lastFetched: now,
expiresAt: getCacheExpiryTime(widget),
};
localStorage.setItem(DASHBOARD_STORAGE_KEYS.CACHE, JSON.stringify(cache));
} else {
setState(prev => ({
...prev, ...prev,
widgets: prev.widgets.map(w => widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
content: cachedData.content,
lastUpdated: new Date(cachedData.lastFetched),
}
: w,
),
}));
return;
}
// Set loading state
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id ? { ...w, isLoading: true, error: null } : w,
),
}));
try {
// Check if prompt uses location variable and request permission if needed
let location: string | undefined;
if (widget.prompt.includes('{{location}}')) {
location = await requestLocationPermission();
}
// Replace date/time variables on the client side
const processedPrompt = replaceDateTimeVariables(widget.prompt);
const response = await fetch('/api/dashboard/process-widget', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: widget.sources,
prompt: processedPrompt,
provider: widget.provider,
model: widget.model,
location,
}),
});
const result = await response.json();
const now = new Date();
if (result.success) {
// Update widget
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
isLoading: false,
content: result.content,
lastUpdated: now,
error: null,
}
: w,
),
}));
// Cache the result
const cache = getWidgetCache();
cache[id] = {
content: result.content,
lastFetched: now,
expiresAt: getCacheExpiryTime(widget),
};
localStorage.setItem(
DASHBOARD_STORAGE_KEYS.CACHE,
JSON.stringify(cache),
);
} else {
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id
? {
...w,
isLoading: false,
error: result.error || 'Failed to refresh widget',
}
: w,
),
}));
}
} catch (error) {
setState((prev) => ({
...prev,
widgets: prev.widgets.map((w) =>
w.id === id w.id === id
? { ? {
...w, ...w,
isLoading: false, isLoading: false,
error: result.error || 'Failed to refresh widget', error: 'Network error: Failed to refresh widget',
} }
: w : w,
), ),
})); }));
} }
} catch (error) { },
setState(prev => ({ [state.widgets],
...prev, );
widgets: prev.widgets.map(w =>
w.id === id
? {
...w,
isLoading: false,
error: 'Network error: Failed to refresh widget',
}
: w
),
}));
}
}, [state.widgets]);
const refreshAllWidgets = useCallback(async () => { const refreshAllWidgets = useCallback(
const activeWidgets = state.widgets.filter(w => !w.isLoading); async (forceRefresh = false) => {
const activeWidgets = state.widgets.filter((w) => !w.isLoading);
if (state.settings.parallelLoading) {
// Refresh all widgets in parallel (force refresh) if (state.settings.parallelLoading) {
await Promise.all(activeWidgets.map(widget => refreshWidget(widget.id, true))); // Refresh all widgets in parallel (force refresh)
} else { await Promise.all(
// Refresh widgets sequentially (force refresh) activeWidgets.map((widget) => refreshWidget(widget.id, forceRefresh)),
for (const widget of activeWidgets) { );
await refreshWidget(widget.id, true); } else {
// Refresh widgets sequentially (force refresh)
for (const widget of activeWidgets) {
await refreshWidget(widget.id, forceRefresh);
}
} }
} },
}, [state.widgets, state.settings.parallelLoading, refreshWidget]); [state.widgets, state.settings.parallelLoading, refreshWidget],
);
const exportDashboard = useCallback(async (): Promise<string> => { const exportDashboard = useCallback(async (): Promise<string> => {
const dashboardConfig: DashboardConfig = { const dashboardConfig: DashboardConfig = {
@ -349,45 +384,57 @@ export const useDashboard = (): UseDashboardReturn => {
return JSON.stringify(dashboardConfig, null, 2); return JSON.stringify(dashboardConfig, null, 2);
}, [state.widgets, state.settings]); }, [state.widgets, state.settings]);
const importDashboard = useCallback(async (configJson: string): Promise<void> => { const importDashboard = useCallback(
try { async (configJson: string): Promise<void> => {
const config: DashboardConfig = JSON.parse(configJson); try {
const config: DashboardConfig = JSON.parse(configJson);
// Validate the config structure
if (!config.widgets || !Array.isArray(config.widgets)) { // Validate the config structure
throw new Error('Invalid dashboard configuration: missing or invalid widgets array'); if (!config.widgets || !Array.isArray(config.widgets)) {
throw new Error(
'Invalid dashboard configuration: missing or invalid widgets array',
);
}
// Process widgets and ensure they have valid IDs
const processedWidgets: Widget[] = config.widgets.map((widget) => ({
...widget,
id:
widget.id ||
Date.now().toString() + Math.random().toString(36).substr(2, 9),
lastUpdated: widget.lastUpdated ? new Date(widget.lastUpdated) : null,
isLoading: false,
content: widget.content || null,
error: null,
}));
setState((prev) => ({
...prev,
widgets: processedWidgets,
settings: { ...prev.settings, ...config.settings },
}));
} catch (error) {
throw new Error(
`Failed to import dashboard: ${error instanceof Error ? error.message : 'Invalid JSON'}`,
);
} }
},
// Process widgets and ensure they have valid IDs [],
const processedWidgets: Widget[] = config.widgets.map(widget => ({ );
...widget,
id: widget.id || Date.now().toString() + Math.random().toString(36).substr(2, 9),
lastUpdated: widget.lastUpdated ? new Date(widget.lastUpdated) : null,
isLoading: false,
content: widget.content || null,
error: null,
}));
setState(prev => ({
...prev,
widgets: processedWidgets,
settings: { ...prev.settings, ...config.settings },
}));
} catch (error) {
throw new Error(`Failed to import dashboard: ${error instanceof Error ? error.message : 'Invalid JSON'}`);
}
}, []);
const clearCache = useCallback(() => { const clearCache = useCallback(() => {
localStorage.removeItem(DASHBOARD_STORAGE_KEYS.CACHE); localStorage.removeItem(DASHBOARD_STORAGE_KEYS.CACHE);
}, []); }, []);
const updateSettings = useCallback((newSettings: Partial<DashboardConfig['settings']>) => { const updateSettings = useCallback(
setState(prev => ({ (newSettings: Partial<DashboardConfig['settings']>) => {
...prev, setState((prev) => ({
settings: { ...prev.settings, ...newSettings }, ...prev,
})); settings: { ...prev.settings, ...newSettings },
}, []); }));
},
[],
);
return { return {
// State // State
@ -395,19 +442,19 @@ export const useDashboard = (): UseDashboardReturn => {
isLoading: state.isLoading, isLoading: state.isLoading,
error: state.error, error: state.error,
settings: state.settings, settings: state.settings,
// Widget management // Widget management
addWidget, addWidget,
updateWidget, updateWidget,
deleteWidget, deleteWidget,
refreshWidget, refreshWidget,
refreshAllWidgets, refreshAllWidgets,
// Storage management // Storage management
exportDashboard, exportDashboard,
importDashboard, importDashboard,
clearCache, clearCache,
// Settings // Settings
updateSettings, updateSettings,
}; };