feat(dashboard): Implement Widget Configuration and Display Components

- Added WidgetConfigModal for creating and editing widgets with fields for title, sources, prompt, provider, model, and refresh frequency.
- Integrated MarkdownRenderer for displaying widget content previews.
- Created WidgetDisplay component to show widget details, including loading states, error handling, and source information.
- Developed a reusable Card component structure for consistent UI presentation.
- Introduced useDashboard hook for managing widget state, including adding, updating, deleting, and refreshing widgets.
- Implemented local storage management for dashboard state and settings.
- Added types for widgets, dashboard configuration, and API requests/responses to improve type safety and clarity.
This commit is contained in:
Willie Zutz 2025-07-19 08:23:06 -06:00
parent a027ccb25a
commit 1228beb59a
11 changed files with 1852 additions and 115 deletions

View file

@ -0,0 +1,216 @@
import { NextRequest, NextResponse } from 'next/server';
import { getWebContent, getWebContentLite } from '@/lib/utils/documents';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatOpenAI } from '@langchain/openai';
import { HumanMessage } from '@langchain/core/messages';
import { getAvailableChatModelProviders } from '@/lib/providers';
import {
getCustomOpenaiApiKey,
getCustomOpenaiApiUrl,
getCustomOpenaiModelName,
} from '@/lib/config';
import { ChatOllama } from '@langchain/ollama';
interface Source {
url: string;
type: 'Web Page' | 'HTTP Data';
}
interface WidgetProcessRequest {
sources: Source[];
prompt: string;
provider: string;
model: string;
}
// Helper function to fetch content from a single source
async function fetchSourceContent(source: Source): Promise<{ content: string; error?: string }> {
try {
let document;
if (source.type === 'Web Page') {
// Use headless browser for complex web pages
document = await getWebContent(source.url);
} else {
// Use faster fetch for HTTP data/APIs
document = await getWebContentLite(source.url);
}
if (!document) {
return {
content: '',
error: `Failed to fetch content from ${source.url}`
};
}
return { content: document.pageContent };
} catch (error) {
console.error(`Error fetching content from ${source.url}:`, error);
return {
content: '',
error: `Error fetching ${source.url}: ${error instanceof Error ? error.message : 'Unknown error'}`
};
}
}
// Helper function to replace variables in prompt
function replacePromptVariables(prompt: string, sourceContents: string[], location?: string): string {
let processedPrompt = prompt;
// Replace source content variables
sourceContents.forEach((content, index) => {
const variable = `{{source_content_${index + 1}}}`;
processedPrompt = processedPrompt.replace(new RegExp(variable, 'g'), content);
});
// Replace location if provided
if (location) {
processedPrompt = processedPrompt.replace(/\{\{location\}\}/g, location);
}
return processedPrompt;
}
// Helper function to get LLM instance based on provider and model
async function getLLMInstance(provider: string, model: string): Promise<BaseChatModel | null> {
try {
const chatModelProviders = await getAvailableChatModelProviders();
if (provider === 'custom_openai') {
return new ChatOpenAI({
modelName: model || getCustomOpenaiModelName(),
openAIApiKey: getCustomOpenaiApiKey(),
configuration: {
baseURL: getCustomOpenaiApiUrl(),
},
}) as unknown as BaseChatModel;
}
if (chatModelProviders[provider] && chatModelProviders[provider][model]) {
const llm = chatModelProviders[provider][model].model as BaseChatModel;
// Special handling for Ollama models
if (llm instanceof ChatOllama && provider === 'ollama') {
llm.numCtx = 2048; // Default context window
}
return llm;
}
return null;
} catch (error) {
console.error('Error getting LLM instance:', error);
return null;
}
}
// Helper function to process the prompt with LLM
async function processWithLLM(prompt: string, provider: string, model: string): Promise<string> {
const llm = await getLLMInstance(provider, model);
if (!llm) {
throw new Error(`Invalid or unavailable model: ${provider}/${model}`);
}
const message = new HumanMessage({ content: prompt });
const response = await llm.invoke([message]);
return response.content as string;
}
export async function POST(request: NextRequest) {
try {
const body: WidgetProcessRequest = await request.json();
// Validate required fields
if (!body.sources || !body.prompt || !body.provider || !body.model) {
return NextResponse.json(
{ error: 'Missing required fields: sources, prompt, provider, model' },
{ status: 400 }
);
}
// Validate sources
if (!Array.isArray(body.sources) || body.sources.length === 0) {
return NextResponse.json(
{ error: 'At least one source URL is required' },
{ status: 400 }
);
}
// 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('Processed prompt:', processedPrompt.substring(0, 200) + '...');
// Process with LLM
try {
const llmResponse = await processWithLLM(processedPrompt, body.provider, body.model);
return NextResponse.json({
content: llmResponse,
success: true,
sourcesFetched: sourceContents.filter(content => content).length,
totalSources: body.sources.length,
warnings: fetchErrors.length > 0 ? fetchErrors : undefined
});
} catch (llmError) {
console.error('LLM processing failed:', llmError);
// Return diagnostic information if LLM fails
const diagnosticResponse = `# Widget Processing - LLM Error
**Error:** ${llmError instanceof Error ? llmError.message : 'Unknown LLM error'}
## Processed Prompt (for debugging)
${processedPrompt}
## Sources Successfully Fetched
${sourceContents.filter(content => content).length} of ${body.sources.length} sources
${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`;
return NextResponse.json({
content: diagnosticResponse,
success: false,
error: llmError instanceof Error ? llmError.message : 'LLM processing failed',
sourcesFetched: sourceContents.filter(content => content).length,
totalSources: body.sources.length
});
}
} catch (error) {
console.error('Error processing widget:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}

220
src/app/dashboard/page.tsx Normal file
View file

@ -0,0 +1,220 @@
'use client';
import { Plus, RefreshCw, Download, Upload, LayoutDashboard, Layers, List } from 'lucide-react';
import { useState } from 'react';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal';
import WidgetDisplay from '@/components/dashboard/WidgetDisplay';
import { useDashboard } from '@/lib/hooks/useDashboard';
import { Widget, WidgetConfig } from '@/lib/types';
const DashboardPage = () => {
const {
widgets,
isLoading,
addWidget,
updateWidget,
deleteWidget,
refreshWidget,
refreshAllWidgets,
exportDashboard,
importDashboard,
settings,
updateSettings,
} = useDashboard();
const [showAddModal, setShowAddModal] = useState(false);
const [editingWidget, setEditingWidget] = useState<Widget | null>(null); const handleAddWidget = () => {
setEditingWidget(null);
setShowAddModal(true);
};
const handleEditWidget = (widget: Widget) => {
setEditingWidget(widget);
setShowAddModal(true);
};
const handleSaveWidget = (widgetConfig: WidgetConfig) => {
if (editingWidget) {
// Update existing widget
updateWidget(editingWidget.id, widgetConfig);
} else {
// Add new widget
addWidget(widgetConfig);
}
setShowAddModal(false);
setEditingWidget(null);
};
const handleCloseModal = () => {
setShowAddModal(false);
setEditingWidget(null);
};
const handleDeleteWidget = (widgetId: string) => {
deleteWidget(widgetId);
};
const handleRefreshWidget = (widgetId: string) => {
refreshWidget(widgetId, true); // Force refresh when manually triggered
};
const handleRefreshAll = () => {
refreshAllWidgets();
};
const handleExport = async () => {
try {
const configJson = await exportDashboard();
await navigator.clipboard.writeText(configJson);
// TODO: Add toast notification for success
console.log('Dashboard configuration copied to clipboard');
} catch (error) {
console.error('Export failed:', error);
// TODO: Add toast notification for error
}
};
const handleImport = async () => {
try {
const configJson = await navigator.clipboard.readText();
await importDashboard(configJson);
// TODO: Add toast notification for success
console.log('Dashboard configuration imported successfully');
} catch (error) {
console.error('Import failed:', error);
// TODO: Add toast notification for error
}
};
const handleToggleProcessingMode = () => {
updateSettings({ parallelLoading: !settings.parallelLoading });
};
// Empty state component
const EmptyDashboard = () => (
<div className="col-span-2 flex justify-center items-center min-h-[400px]">
<Card className="w-96 text-center">
<CardHeader>
<CardTitle>Welcome to your Dashboard</CardTitle>
<CardDescription>
Create your first widget to get started with personalized information
</CardDescription>
</CardHeader>
<CardContent>
<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.
</p>
</CardContent>
<CardFooter className="justify-center">
<button
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"
>
<Plus size={16} />
<span>Create Your First Widget</span>
</button>
</CardFooter>
</Card>
</div>
);
return (
<div className="flex flex-col min-h-screen">
{/* Header matching other pages */}
<div className="flex flex-col pt-4">
<div className="flex items-center justify-between">
<div className="flex items-center">
<LayoutDashboard />
<h1 className="text-3xl font-medium p-2">Dashboard</h1>
</div>
<div className="flex items-center space-x-2">
<button
onClick={handleRefreshAll}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
title="Refresh All Widgets"
>
<RefreshCw size={18} className="text-black dark:text-white" />
</button>
<button
onClick={handleToggleProcessingMode}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
title={`Switch to ${settings.parallelLoading ? 'Sequential' : 'Parallel'} Processing`}
>
{settings.parallelLoading ? (
<Layers size={18} className="text-black dark:text-white" />
) : (
<List size={18} className="text-black dark:text-white" />
)}
</button>
<button
onClick={handleExport}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
title="Export Dashboard Configuration"
>
<Download size={18} className="text-black dark:text-white" />
</button>
<button
onClick={handleImport}
className="p-2 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded-lg transition duration-200"
title="Import Dashboard Configuration"
>
<Upload size={18} className="text-black dark:text-white" />
</button>
<button
onClick={handleAddWidget}
className="p-2 bg-blue-600 hover:bg-blue-700 rounded-lg transition duration-200"
title="Add New Widget"
>
<Plus size={18} className="text-white" />
</button>
</div>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
</div>
{/* Main content area */}
<div className="flex-1 pb-20 lg:pb-2">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<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>
<p className="text-gray-500 dark:text-gray-400">Loading dashboard...</p>
</div>
</div>
) : widgets.length === 0 ? (
<EmptyDashboard />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 auto-rows-min">
{widgets.map((widget) => (
<WidgetDisplay
key={widget.id}
widget={widget}
onEdit={handleEditWidget}
onDelete={handleDeleteWidget}
onRefresh={handleRefreshWidget}
/>
))}
</div>
)}
</div>
{/* Widget Configuration Modal */}
<WidgetConfigModal
isOpen={showAddModal}
onClose={handleCloseModal}
onSave={handleSaveWidget}
editingWidget={editingWidget}
/>
</div>
);
};
export default DashboardPage;

View file

@ -0,0 +1,191 @@
/* eslint-disable @next/next/no-img-element */
'use client';
import { cn } from '@/lib/utils';
import { CheckCheck, Copy as CopyIcon, Brain } from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import ThinkBox from './ThinkBox';
// Helper functions for think overlay
const extractThinkContent = (content: string): string | null => {
const thinkRegex = /<think>([\s\S]*?)<\/think>/g;
const matches = content.match(thinkRegex);
if (!matches) return null;
// Extract content between think tags and join if multiple
const extractedContent = matches
.map(match => match.replace(/<\/?think>/g, ''))
.join('\n\n');
// Return null if content is empty or only whitespace
return extractedContent.trim().length === 0 ? null : extractedContent;
};
const removeThinkTags = (content: string): string => {
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
};
const ThinkTagProcessor = ({
children,
isOverlayMode = false
}: {
children: React.ReactNode;
isOverlayMode?: boolean;
}) => {
// In overlay mode, don't render anything (content will be handled by overlay)
if (isOverlayMode) {
return null;
}
return <ThinkBox content={children as string} />;
};
const CodeBlock = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
// Extract language from className (format could be "language-javascript" or "lang-javascript")
let language = '';
if (className) {
if (className.startsWith('language-')) {
language = className.replace('language-', '');
} else if (className.startsWith('lang-')) {
language = className.replace('lang-', '');
}
}
const content = children as string;
const [isCopied, setIsCopied] = useState(false);
const handleCopyCode = () => {
navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className="rounded-md overflow-hidden my-4 relative group border border-dark-secondary">
<div className="flex justify-between items-center px-4 py-2 bg-dark-200 border-b border-dark-secondary text-xs text-white/70 font-mono">
<span>{language}</span>
<button
onClick={handleCopyCode}
className="p-1 rounded-md hover:bg-dark-secondary transition duration-200"
aria-label="Copy code to clipboard"
>
{isCopied ? (
<CheckCheck size={14} className="text-green-500" />
) : (
<CopyIcon size={14} className="text-white/70" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
customStyle={{
margin: 0,
padding: '1rem',
borderRadius: 0,
backgroundColor: '#1c1c1c',
}}
wrapLines={true}
wrapLongLines={true}
showLineNumbers={language !== '' && content.split('\n').length > 1}
useInlineStyles={true}
PreTag="div"
>
{content}
</SyntaxHighlighter>
</div>
);
};
interface MarkdownRendererProps {
content: string;
className?: string;
thinkOverlay?: boolean;
}
const MarkdownRenderer = ({ content, className, thinkOverlay = false }: MarkdownRendererProps) => {
const [showThinkBox, setShowThinkBox] = useState(false);
// Extract think content from the markdown
const thinkContent = thinkOverlay ? extractThinkContent(content) : null;
const contentWithoutThink = thinkOverlay ? removeThinkTags(content) : content;
// Markdown formatting options
const markdownOverrides: MarkdownToJSX.Options = {
overrides: {
think: {
component: ({ children }) => (
<ThinkTagProcessor isOverlayMode={thinkOverlay}>
{children}
</ThinkTagProcessor>
),
},
code: {
component: ({ className, children }) => {
// Check if it's an inline code block or a fenced code block
if (className) {
// This is a fenced code block (```code```)
return <CodeBlock className={className}>{children}</CodeBlock>;
}
// This is an inline code block (`code`)
return (
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
{children}
</code>
);
},
},
pre: {
component: ({ children }) => children,
},
},
};
return (
<div className="relative">
{/* Think box when expanded - shows above markdown */}
{thinkOverlay && thinkContent && showThinkBox && (
<div className="mb-4">
<ThinkBox
content={thinkContent}
expanded={true}
onToggle={() => setShowThinkBox(false)}
/>
</div>
)}
<Markdown
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-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',
'max-w-none break-words text-black dark:text-white',
className
)}
options={markdownOverrides}
>
{thinkOverlay ? contentWithoutThink : content}
</Markdown>
{/* Overlay icon when think box is collapsed */}
{thinkOverlay && thinkContent && !showThinkBox && (
<button
onClick={() => setShowThinkBox(true)}
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"
>
<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>
)}
</div>
);
};
export default MarkdownRenderer;

View file

@ -5,8 +5,6 @@ import { getSuggestions } from '@/lib/actions';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
BookCopy, BookCopy,
CheckCheck,
Copy as CopyIcon,
Disc3, Disc3,
ImagesIcon, ImagesIcon,
Layers3, Layers3,
@ -16,86 +14,16 @@ import {
VideoIcon, VideoIcon,
Volume2, Volume2,
} from 'lucide-react'; } from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useSpeech } from 'react-text-to-speech'; import { useSpeech } from 'react-text-to-speech';
import { Message } from './ChatWindow'; import { Message } from './ChatWindow';
import MarkdownRenderer from './MarkdownRenderer';
import Copy from './MessageActions/Copy'; import Copy from './MessageActions/Copy';
import ModelInfoButton from './MessageActions/ModelInfo'; import ModelInfoButton from './MessageActions/ModelInfo';
import Rewrite from './MessageActions/Rewrite'; import Rewrite from './MessageActions/Rewrite';
import MessageSources from './MessageSources'; import MessageSources from './MessageSources';
import SearchImages from './SearchImages'; import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos'; import SearchVideos from './SearchVideos';
import ThinkBox from './ThinkBox';
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
return <ThinkBox content={children as string} />;
};
const CodeBlock = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
// Extract language from className (format could be "language-javascript" or "lang-javascript")
let language = '';
if (className) {
if (className.startsWith('language-')) {
language = className.replace('language-', '');
} else if (className.startsWith('lang-')) {
language = className.replace('lang-', '');
}
}
const content = children as string;
const [isCopied, setIsCopied] = useState(false);
const handleCopyCode = () => {
navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className="rounded-md overflow-hidden my-4 relative group border border-dark-secondary">
<div className="flex justify-between items-center px-4 py-2 bg-dark-200 border-b border-dark-secondary text-xs text-white/70 font-mono">
<span>{language}</span>
<button
onClick={handleCopyCode}
className="p-1 rounded-md hover:bg-dark-secondary transition duration-200"
aria-label="Copy code to clipboard"
>
{isCopied ? (
<CheckCheck size={14} className="text-green-500" />
) : (
<CopyIcon size={14} className="text-white/70" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
customStyle={{
margin: 0,
padding: '1rem',
borderRadius: 0,
backgroundColor: '#1c1c1c',
}}
wrapLines={true}
wrapLongLines={true}
showLineNumbers={language !== '' && content.split('\n').length > 1}
useInlineStyles={true}
PreTag="div"
>
{content}
</SyntaxHighlighter>
</div>
);
};
type TabType = 'text' | 'sources' | 'images' | 'videos'; type TabType = 'text' | 'sources' | 'images' | 'videos';
@ -236,33 +164,6 @@ const MessageTabs = ({
} }
}, [isLast, loading, message.role, handleLoadSuggestions]); }, [isLast, loading, message.role, handleLoadSuggestions]);
// Markdown formatting options
const markdownOverrides: MarkdownToJSX.Options = {
overrides: {
think: {
component: ThinkTagProcessor,
},
code: {
component: ({ className, children }) => {
// Check if it's an inline code block or a fenced code block
if (className) {
// This is a fenced code block (```code```)
return <CodeBlock className={className}>{children}</CodeBlock>;
}
// This is an inline code block (`code`)
return (
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
{children}
</code>
);
},
},
pre: {
component: ({ children }) => children,
},
},
};
return ( return (
<div className="flex flex-col w-full"> <div className="flex flex-col w-full">
{/* Tabs */} {/* Tabs */}
@ -372,17 +273,10 @@ 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">
<Markdown <MarkdownRenderer
className={cn( content={parsedMessage}
'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]', className="px-4"
'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',
'max-w-none break-words px-4 text-black dark:text-white',
)}
options={markdownOverrides}
>
{parsedMessage}
</Markdown>
{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,7 @@
'use client'; 'use client';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { BookOpenText, Home, Search, SquarePen, Settings } 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';
@ -23,6 +23,12 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
active: segments.length === 0 || segments.includes('c'), active: segments.length === 0 || segments.includes('c'),
label: 'Home', label: 'Home',
}, },
{
icon: LayoutDashboard,
href: '/dashboard',
active: segments.includes('dashboard'),
label: 'Dashboard',
},
{ {
icon: Search, icon: Search,
href: '/discover', href: '/discover',

View file

@ -6,15 +6,26 @@ import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
interface ThinkBoxProps { interface ThinkBoxProps {
content: string; content: string;
expanded?: boolean;
onToggle?: () => void;
} }
const ThinkBox = ({ content }: ThinkBoxProps) => { const ThinkBox = ({ content, expanded, onToggle }: ThinkBoxProps) => {
const [isExpanded, setIsExpanded] = useState(false); // Don't render anything if content is empty or only whitespace
if (!content || content.trim().length === 0) {
return null;
}
const [internalExpanded, setInternalExpanded] = useState(false);
// Use external expanded state if provided, otherwise use internal state
const isExpanded = expanded !== undefined ? expanded : 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">
<button <button
onClick={() => setIsExpanded(!isExpanded)} onClick={handleToggle}
className="w-full flex items-center justify-between px-4 py-1 text-black/90 dark:text-white/90 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200" className="w-full flex items-center justify-between px-4 py-1 text-black/90 dark:text-white/90 hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">

View file

@ -0,0 +1,415 @@
'use client';
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
import { X, Plus, Trash2, Play, Save } from 'lucide-react';
import { Fragment, useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/MarkdownRenderer';
import ModelSelector from '@/components/MessageInputActions/ModelSelector';
// Helper function to replace date/time variables in prompts on the client side
const replaceDateTimeVariables = (prompt: string): string => {
let processedPrompt = prompt;
// Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime);
}
// Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime);
}
return processedPrompt;
};
interface Source {
url: string;
type: 'Web Page' | 'HTTP Data';
}
interface WidgetConfig {
id?: string;
title: string;
sources: Source[];
prompt: string;
provider: string;
model: string;
refreshFrequency: number;
refreshUnit: 'minutes' | 'hours';
}
interface WidgetConfigModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (config: WidgetConfig) => void;
editingWidget?: WidgetConfig | null;
}
const WidgetConfigModal = ({
isOpen,
onClose,
onSave,
editingWidget,
}: WidgetConfigModalProps) => {
const [config, setConfig] = useState<WidgetConfig>({
title: '',
sources: [{ url: '', type: 'Web Page' }],
prompt: '',
provider: 'openai',
model: 'gpt-4',
refreshFrequency: 60,
refreshUnit: 'minutes',
});
const [previewContent, setPreviewContent] = useState<string>('');
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
const [selectedModel, setSelectedModel] = useState<{ provider: string; model: string } | null>(null);
// Update config when editingWidget changes
useEffect(() => {
if (editingWidget) {
setConfig({
title: editingWidget.title,
sources: editingWidget.sources,
prompt: editingWidget.prompt,
provider: editingWidget.provider,
model: editingWidget.model,
refreshFrequency: editingWidget.refreshFrequency,
refreshUnit: editingWidget.refreshUnit,
});
setSelectedModel({
provider: editingWidget.provider,
model: editingWidget.model,
});
} else {
// Reset to default values for new widget
setConfig({
title: '',
sources: [{ url: '', type: 'Web Page' }],
prompt: '',
provider: 'openai',
model: 'gpt-4',
refreshFrequency: 60,
refreshUnit: 'minutes',
});
setSelectedModel({
provider: 'openai',
model: 'gpt-4',
});
}
}, [editingWidget]);
// Update config when model selection changes
useEffect(() => {
if (selectedModel) {
setConfig(prev => ({
...prev,
provider: selectedModel.provider,
model: selectedModel.model,
}));
}
}, [selectedModel]);
const handleSave = () => {
if (!config.title.trim() || !config.prompt.trim()) {
return; // TODO: Add proper validation feedback
}
onSave(config);
onClose();
};
const handlePreview = async () => {
if (!config.prompt.trim()) {
setPreviewContent('Please enter a prompt before running preview.');
return;
}
if (config.sources.length === 0 || config.sources.every(s => !s.url.trim())) {
setPreviewContent('Please add at least one source URL before running preview.');
return;
}
setIsPreviewLoading(true);
try {
// Replace date/time variables on the client side
const processedPrompt = replaceDateTimeVariables(config.prompt);
const response = await fetch('/api/dashboard/process-widget', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
sources: config.sources.filter(s => s.url.trim()), // Only send sources with URLs
prompt: processedPrompt,
provider: config.provider,
model: config.model,
}),
});
const result = await response.json();
if (result.success) {
setPreviewContent(result.content);
} else {
setPreviewContent(`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`);
}
} catch (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'}`);
} finally {
setIsPreviewLoading(false);
}
};
const addSource = () => {
setConfig(prev => ({
...prev,
sources: [...prev.sources, { url: '', type: 'Web Page' }]
}));
};
const removeSource = (index: number) => {
setConfig(prev => ({
...prev,
sources: prev.sources.filter((_, i) => i !== index)
}));
};
const updateSource = (index: number, field: keyof Source, value: string) => {
setConfig(prev => ({
...prev,
sources: prev.sources.map((source, i) =>
i === index ? { ...source, [field]: value } : source
)
}));
};
return (
<Transition appear show={isOpen} as={Fragment}>
<Dialog as="div" className="relative z-50" onClose={onClose}>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black bg-opacity-25" />
</TransitionChild>
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-4xl transform overflow-hidden rounded-2xl bg-light-primary dark:bg-dark-primary p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-medium leading-6 text-black dark:text-white flex items-center justify-between"
>
{editingWidget ? 'Edit Widget' : 'Create New Widget'}
<button
onClick={onClose}
className="p-1 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded"
>
<X size={20} />
</button>
</DialogTitle>
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Left Column - Configuration */}
<div className="space-y-4">
{/* Widget Title */}
<div>
<label className="block text-sm font-medium text-black dark:text-white mb-1">
Widget Title
</label>
<input
type="text"
value={config.title}
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"
placeholder="Enter widget title..."
/>
</div>
{/* Source URLs */}
<div>
<label className="block text-sm font-medium text-black dark:text-white mb-1">
Source URLs
</label>
<div className="space-y-2">
{config.sources.map((source, index) => (
<div key={index} className="flex gap-2">
<input
type="url"
value={source.url}
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"
placeholder="https://example.com"
/>
<select
value={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"
>
<option value="Web Page">Web Page</option>
<option value="HTTP Data">HTTP Data</option>
</select>
{config.sources.length > 1 && (
<button
onClick={() => removeSource(index)}
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded"
>
<Trash2 size={16} />
</button>
)}
</div>
))}
<button
onClick={addSource}
className="flex items-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded"
>
<Plus size={16} />
Add Source
</button>
</div>
</div>
{/* LLM Prompt */}
<div>
<label className="block text-sm font-medium text-black dark:text-white mb-1">
LLM Prompt
</label>
<textarea
value={config.prompt}
onChange={(e) => setConfig(prev => ({ ...prev, prompt: e.target.value }))}
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"
placeholder="Enter your prompt here..."
/>
</div>
{/* Provider and Model Selection */}
<div>
<label className="block text-sm font-medium text-black dark:text-white mb-2">
Model & Provider
</label>
<ModelSelector
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
truncateModelName={false}
showModelName={true}
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Select the AI model and provider to process your widget content
</p>
</div>
{/* Refresh Frequency */}
<div>
<label className="block text-sm font-medium text-black dark:text-white mb-1">
Refresh Frequency
</label>
<div className="flex gap-2">
<input
type="number"
min="1"
value={config.refreshFrequency}
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"
/>
<select
value={config.refreshUnit}
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"
>
<option value="minutes">Minutes</option>
<option value="hours">Hours</option>
</select>
</div>
</div>
</div>
{/* Right Column - Preview */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-black dark:text-white">
Preview
</h4>
<button
onClick={handlePreview}
disabled={isPreviewLoading}
className="flex items-center gap-2 px-3 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
<Play size={16} />
{isPreviewLoading ? 'Loading...' : 'Run Preview'}
</button>
</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">
{previewContent ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownRenderer thinkOverlay={true} content={previewContent} />
</div>
) : (
<div className="text-sm text-black/50 dark:text-white/50 italic">
Click "Run Preview" to see how your widget will look
</div>
)}
</div>
{/* Variable Legend */}
<div className="text-xs text-black/70 dark:text-white/70">
<h5 className="font-medium mb-2">Available Variables:</h5>
<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><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>
{/* Action Buttons */}
<div className="mt-6 flex justify-end gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-black dark:text-white bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 rounded-md"
>
Cancel
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md"
>
<Save size={16} />
{editingWidget ? 'Update Widget' : 'Create Widget'}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
};
export default WidgetConfigModal;

View file

@ -0,0 +1,179 @@
'use client';
import { RefreshCw, Edit, Trash2, Clock, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import MarkdownRenderer from '@/components/MarkdownRenderer';
import { useState } from 'react';
interface Source {
url: string;
type: 'Web Page' | 'HTTP Data';
}
interface Widget {
id: string;
title: string;
sources: Source[];
prompt: string;
provider: string;
model: string;
refreshFrequency: number;
refreshUnit: 'minutes' | 'hours';
lastUpdated: Date | null;
isLoading: boolean;
content: string | null;
error: string | null;
}
interface WidgetDisplayProps {
widget: Widget;
onEdit: (widget: Widget) => void;
onDelete: (widgetId: string) => void;
onRefresh: (widgetId: string) => void;
}
const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayProps) => {
const [isFooterExpanded, setIsFooterExpanded] = useState(false);
const formatLastUpdated = (date: Date | null) => {
if (!date) return 'Never';
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
};
const getRefreshFrequencyText = () => {
return `Every ${widget.refreshFrequency} ${widget.refreshUnit}`;
};
return (
<Card className="flex flex-col h-fit">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg font-medium truncate">
{widget.title}
</CardTitle>
<div className="flex items-center space-x-2">
{/* Last updated date with refresh frequency tooltip */}
<span
className="text-xs text-gray-500 dark:text-gray-400"
title={getRefreshFrequencyText()}
>
{formatLastUpdated(widget.lastUpdated)}
</span>
{/* Refresh button */}
<button
onClick={() => onRefresh(widget.id)}
disabled={widget.isLoading}
className="p-1.5 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded transition-colors disabled:opacity-50"
title="Refresh Widget"
>
<RefreshCw
size={16}
className={`text-gray-600 dark:text-gray-400 ${widget.isLoading ? 'animate-spin' : ''}`}
/>
</button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1">
{widget.isLoading ? (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<RefreshCw size={20} className="animate-spin mr-2" />
<span>Loading content...</span>
</div>
) : 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">
<AlertCircle size={16} className="text-red-500 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<p className="text-sm font-medium text-red-800 dark:text-red-300">Error Loading Content</p>
<p className="text-xs text-red-600 dark:text-red-400 mt-1">{widget.error}</p>
</div>
</div>
) : widget.content ? (
<div className="prose prose-sm dark:prose-invert max-w-none">
<MarkdownRenderer content={widget.content} thinkOverlay={true} />
</div>
) : (
<div className="flex items-center justify-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-center">
<p className="text-sm">No content yet</p>
<p className="text-xs mt-1">Click refresh to load content</p>
</div>
</div>
)}
</CardContent>
{/* Collapsible footer with sources and actions */}
<div className="bg-light-secondary/30 dark:bg-dark-secondary/30">
<button
onClick={() => setIsFooterExpanded(!isFooterExpanded)}
className="w-full px-4 py-2 flex items-center space-x-2 text-xs text-gray-500 dark:text-gray-400 hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors"
>
{isFooterExpanded ? (
<ChevronUp size={14} />
) : (
<ChevronDown size={14} />
)}
<span>Sources & Actions</span>
</button>
{isFooterExpanded && (
<div className="px-4 pb-4 space-y-3">
{/* Sources */}
{widget.sources.length > 0 && (
<div>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Sources:</p>
<div className="space-y-1">
{widget.sources.map((source, index) => (
<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="text-gray-600 dark:text-gray-300 truncate">
{source.url}
</span>
<span className="text-gray-400 dark:text-gray-500">
({source.type})
</span>
</div>
))}
</div>
</div>
)}
{/* Action buttons */}
<div className="flex items-center space-x-2 pt-2">
<button
onClick={() => onEdit(widget)}
className="flex items-center space-x-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded transition-colors"
>
<Edit size={12} />
<span>Edit</span>
</button>
<button
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"
>
<Trash2 size={12} />
<span>Delete</span>
</button>
</div>
</div>
)}
</div>
</Card>
);
};
export default WidgetDisplay;

108
src/components/ui/card.tsx Normal file
View file

@ -0,0 +1,108 @@
import React from 'react';
import { cn } from '@/lib/utils';
interface CardProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
interface CardHeaderProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
interface CardContentProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
interface CardFooterProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
}
interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
children: React.ReactNode;
}
interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
children: React.ReactNode;
}
const Card = React.forwardRef<HTMLDivElement, CardProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border bg-card text-card-foreground shadow-sm',
className
)}
{...props}
>
{children}
</div>
)
);
Card.displayName = 'Card';
const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
>
{children}
</div>
)
);
CardHeader.displayName = 'CardHeader';
const CardTitle = React.forwardRef<HTMLParagraphElement, CardTitleProps>(
({ className, children, ...props }, ref) => (
<h3
ref={ref}
className={cn(
'text-2xl font-semibold leading-none tracking-tight',
className
)}
{...props}
>
{children}
</h3>
)
);
CardTitle.displayName = 'CardTitle';
const CardDescription = React.forwardRef<HTMLParagraphElement, CardDescriptionProps>(
({ className, children, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
>
{children}
</p>
)
);
CardDescription.displayName = 'CardDescription';
const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
({ className, children, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props}>
{children}
</div>
)
);
CardContent.displayName = 'CardContent';
const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
({ className, children, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
>
{children}
</div>
)
);
CardFooter.displayName = 'CardFooter';
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };

View file

@ -0,0 +1,414 @@
import { useState, useEffect, useCallback } from 'react';
import {
Widget,
WidgetConfig,
DashboardState,
DashboardConfig,
DASHBOARD_STORAGE_KEYS,
WidgetCache
} from '@/lib/types';
// Helper function to request location permission and get user's location
const requestLocationPermission = async (): Promise<string | undefined> => {
try {
if (!navigator.geolocation) {
console.warn('Geolocation is not supported by this browser');
return undefined;
}
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
resolve(`${latitude.toFixed(6)}, ${longitude.toFixed(6)}`);
},
(error) => {
console.warn('Location access denied or failed:', error.message);
// Don't reject, just return undefined to continue without location
resolve(undefined);
},
{
enableHighAccuracy: true,
timeout: 10000, // 10 seconds timeout
maximumAge: 300000, // 5 minutes cache
}
);
});
} catch (error) {
console.warn('Error requesting location:', error);
return undefined;
}
};
// Helper function to replace date/time variables in prompts on the client side
const replaceDateTimeVariables = (prompt: string): string => {
let processedPrompt = prompt;
// Replace UTC datetime
if (processedPrompt.includes('{{current_utc_datetime}}')) {
const utcDateTime = new Date().toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_utc_datetime\}\}/g, utcDateTime);
}
// Replace local datetime
if (processedPrompt.includes('{{current_local_datetime}}')) {
const now = new Date();
const localDateTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString();
processedPrompt = processedPrompt.replace(/\{\{current_local_datetime\}\}/g, localDateTime);
}
return processedPrompt;
};
interface UseDashboardReturn {
// State
widgets: Widget[];
isLoading: boolean;
error: string | null;
settings: DashboardConfig['settings'];
// Widget management
addWidget: (config: WidgetConfig) => void;
updateWidget: (id: string, config: WidgetConfig) => void;
deleteWidget: (id: string) => void;
refreshWidget: (id: string, forceRefresh?: boolean) => Promise<void>;
refreshAllWidgets: () => Promise<void>;
// Storage management
exportDashboard: () => Promise<string>;
importDashboard: (configJson: string) => Promise<void>;
clearCache: () => void;
// Settings
updateSettings: (newSettings: Partial<DashboardConfig['settings']>) => void;
}
export const useDashboard = (): UseDashboardReturn => {
const [state, setState] = useState<DashboardState>({
widgets: [],
isLoading: true, // Start as loading
error: null,
settings: {
parallelLoading: true,
autoRefresh: false,
theme: 'auto',
},
});
// Load dashboard data from localStorage on mount
useEffect(() => {
loadDashboardData();
}, []);
// Save widgets to localStorage whenever they change (but not on initial load)
useEffect(() => {
if (!state.isLoading) {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.WIDGETS, JSON.stringify(state.widgets));
}
}, [state.widgets, state.isLoading]);
// Save settings to localStorage whenever they change
useEffect(() => {
localStorage.setItem(DASHBOARD_STORAGE_KEYS.SETTINGS, JSON.stringify(state.settings));
}, [state.settings]);
const loadDashboardData = useCallback(() => {
try {
// Load widgets
const savedWidgets = localStorage.getItem(DASHBOARD_STORAGE_KEYS.WIDGETS);
const widgets: Widget[] = savedWidgets ? JSON.parse(savedWidgets) : [];
// Convert date strings back to Date objects
widgets.forEach(widget => {
if (widget.lastUpdated) {
widget.lastUpdated = new Date(widget.lastUpdated);
}
});
// Load settings
const savedSettings = localStorage.getItem(DASHBOARD_STORAGE_KEYS.SETTINGS);
const settings = savedSettings ? JSON.parse(savedSettings) : {
parallelLoading: true,
autoRefresh: false,
theme: 'auto',
};
setState(prev => ({
...prev,
widgets,
settings,
isLoading: false,
}));
} catch (error) {
console.error('Error loading dashboard data:', error);
setState(prev => ({
...prev,
error: 'Failed to load dashboard data',
isLoading: false,
}));
}
}, []);
const addWidget = useCallback((config: WidgetConfig) => {
const newWidget: Widget = {
...config,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
lastUpdated: null,
isLoading: false,
content: null,
error: null,
};
setState(prev => ({
...prev,
widgets: [...prev.widgets, newWidget],
}));
}, []);
const updateWidget = useCallback((id: string, config: WidgetConfig) => {
setState(prev => ({
...prev,
widgets: prev.widgets.map(widget =>
widget.id === id
? { ...widget, ...config, id } // Preserve the ID
: widget
),
}));
}, []);
const deleteWidget = useCallback((id: string) => {
setState(prev => ({
...prev,
widgets: prev.widgets.filter(widget => widget.id !== id),
}));
// Also remove from cache
const cache = getWidgetCache();
delete cache[id];
localStorage.setItem(DASHBOARD_STORAGE_KEYS.CACHE, JSON.stringify(cache));
}, []);
const getWidgetCache = (): WidgetCache => {
try {
const cached = localStorage.getItem(DASHBOARD_STORAGE_KEYS.CACHE);
return cached ? JSON.parse(cached) : {};
} catch {
return {};
}
};
const isWidgetCacheValid = (widget: Widget): boolean => {
const cache = getWidgetCache();
const cachedData = cache[widget.id];
if (!cachedData) return false;
const now = new Date();
const expiresAt = new Date(cachedData.expiresAt);
return now < expiresAt;
};
const getCacheExpiryTime = (widget: Widget): Date => {
const now = new Date();
const refreshMs = widget.refreshFrequency * (widget.refreshUnit === 'hours' ? 3600000 : 60000);
return new Date(now.getTime() + refreshMs);
};
const refreshWidget = useCallback(async (id: string, forceRefresh: boolean = false) => {
const widget = state.widgets.find(w => w.id === id);
if (!widget) return;
// Check cache first (unless forcing refresh)
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();
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,
isLoading: false,
error: 'Network error: Failed to refresh widget',
}
: w
),
}));
}
}, [state.widgets]);
const refreshAllWidgets = useCallback(async () => {
const activeWidgets = state.widgets.filter(w => !w.isLoading);
if (state.settings.parallelLoading) {
// Refresh all widgets in parallel (force refresh)
await Promise.all(activeWidgets.map(widget => refreshWidget(widget.id, true)));
} else {
// Refresh widgets sequentially (force refresh)
for (const widget of activeWidgets) {
await refreshWidget(widget.id, true);
}
}
}, [state.widgets, state.settings.parallelLoading, refreshWidget]);
const exportDashboard = useCallback(async (): Promise<string> => {
const dashboardConfig: DashboardConfig = {
widgets: state.widgets,
settings: state.settings,
lastExport: new Date(),
version: '1.0.0',
};
return JSON.stringify(dashboardConfig, null, 2);
}, [state.widgets, state.settings]);
const importDashboard = useCallback(async (configJson: string): Promise<void> => {
try {
const config: DashboardConfig = JSON.parse(configJson);
// Validate the config structure
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'}`);
}
}, []);
const clearCache = useCallback(() => {
localStorage.removeItem(DASHBOARD_STORAGE_KEYS.CACHE);
}, []);
const updateSettings = useCallback((newSettings: Partial<DashboardConfig['settings']>) => {
setState(prev => ({
...prev,
settings: { ...prev.settings, ...newSettings },
}));
}, []);
return {
// State
widgets: state.widgets,
isLoading: state.isLoading,
error: state.error,
settings: state.settings,
// Widget management
addWidget,
updateWidget,
deleteWidget,
refreshWidget,
refreshAllWidgets,
// Storage management
exportDashboard,
importDashboard,
clearCache,
// Settings
updateSettings,
};
};

83
src/lib/types.ts Normal file
View file

@ -0,0 +1,83 @@
// Dashboard-related TypeScript type definitions
export interface Source {
url: string;
type: 'Web Page' | 'HTTP Data';
}
export interface Widget {
id: string;
title: string;
sources: Source[];
prompt: string;
provider: string;
model: string;
refreshFrequency: number;
refreshUnit: 'minutes' | 'hours';
lastUpdated: Date | null;
isLoading: boolean;
content: string | null;
error: string | null;
}
export interface WidgetConfig {
id?: string;
title: string;
sources: Source[];
prompt: string;
provider: string;
model: string;
refreshFrequency: number;
refreshUnit: 'minutes' | 'hours';
}
export interface DashboardConfig {
widgets: Widget[];
settings: {
parallelLoading: boolean;
autoRefresh: boolean;
theme: 'auto' | 'light' | 'dark';
};
lastExport?: Date;
version: string;
}
export interface DashboardState {
widgets: Widget[];
isLoading: boolean;
error: string | null;
settings: DashboardConfig['settings'];
}
// Widget processing API types
export interface WidgetProcessRequest {
sources: Source[];
prompt: string;
provider: string;
model: string;
}
export interface WidgetProcessResponse {
content: string;
success: boolean;
sourcesFetched?: number;
totalSources?: number;
warnings?: string[];
error?: string;
}
// Local storage keys
export const DASHBOARD_STORAGE_KEYS = {
WIDGETS: 'perplexica_dashboard_widgets',
SETTINGS: 'perplexica_dashboard_settings',
CACHE: 'perplexica_dashboard_cache',
} as const;
// Cache types
export interface WidgetCache {
[widgetId: string]: {
content: string;
lastFetched: Date;
expiresAt: Date;
};
}