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:
parent
a027ccb25a
commit
1228beb59a
11 changed files with 1852 additions and 115 deletions
216
src/app/api/dashboard/process-widget/route.ts
Normal file
216
src/app/api/dashboard/process-widget/route.ts
Normal 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
220
src/app/dashboard/page.tsx
Normal 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;
|
||||
191
src/components/MarkdownRenderer.tsx
Normal file
191
src/components/MarkdownRenderer.tsx
Normal 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;
|
||||
|
|
@ -5,8 +5,6 @@ import { getSuggestions } from '@/lib/actions';
|
|||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
BookCopy,
|
||||
CheckCheck,
|
||||
Copy as CopyIcon,
|
||||
Disc3,
|
||||
ImagesIcon,
|
||||
Layers3,
|
||||
|
|
@ -16,86 +14,16 @@ import {
|
|||
VideoIcon,
|
||||
Volume2,
|
||||
} from 'lucide-react';
|
||||
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
|
||||
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 { Message } from './ChatWindow';
|
||||
import MarkdownRenderer from './MarkdownRenderer';
|
||||
import Copy from './MessageActions/Copy';
|
||||
import ModelInfoButton from './MessageActions/ModelInfo';
|
||||
import Rewrite from './MessageActions/Rewrite';
|
||||
import MessageSources from './MessageSources';
|
||||
import SearchImages from './SearchImages';
|
||||
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';
|
||||
|
||||
|
|
@ -236,33 +164,6 @@ const MessageTabs = ({
|
|||
}
|
||||
}, [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 (
|
||||
<div className="flex flex-col w-full">
|
||||
{/* Tabs */}
|
||||
|
|
@ -372,17 +273,10 @@ const MessageTabs = ({
|
|||
{/* Answer Tab */}
|
||||
{activeTab === 'text' && (
|
||||
<div className="flex flex-col space-y-4 animate-fadeIn">
|
||||
<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 px-4 text-black dark:text-white',
|
||||
)}
|
||||
options={markdownOverrides}
|
||||
>
|
||||
{parsedMessage}
|
||||
</Markdown>
|
||||
<MarkdownRenderer
|
||||
content={parsedMessage}
|
||||
className="px-4"
|
||||
/>
|
||||
|
||||
{loading && isLast ? null : (
|
||||
<div className="flex flex-row items-center justify-between w-full text-black dark:text-white px-4 py-4">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client';
|
||||
|
||||
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 { useSelectedLayoutSegments } from 'next/navigation';
|
||||
import React, { useState, type ReactNode } from 'react';
|
||||
|
|
@ -23,6 +23,12 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
|
|||
active: segments.length === 0 || segments.includes('c'),
|
||||
label: 'Home',
|
||||
},
|
||||
{
|
||||
icon: LayoutDashboard,
|
||||
href: '/dashboard',
|
||||
active: segments.includes('dashboard'),
|
||||
label: 'Dashboard',
|
||||
},
|
||||
{
|
||||
icon: Search,
|
||||
href: '/discover',
|
||||
|
|
|
|||
|
|
@ -6,15 +6,26 @@ import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
|
|||
|
||||
interface ThinkBoxProps {
|
||||
content: string;
|
||||
expanded?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const ThinkBox = ({ content }: ThinkBoxProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const ThinkBox = ({ content, expanded, onToggle }: ThinkBoxProps) => {
|
||||
// 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 (
|
||||
<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
|
||||
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"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
|
|
|
|||
415
src/components/dashboard/WidgetConfigModal.tsx
Normal file
415
src/components/dashboard/WidgetConfigModal.tsx
Normal 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;
|
||||
179
src/components/dashboard/WidgetDisplay.tsx
Normal file
179
src/components/dashboard/WidgetDisplay.tsx
Normal 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
108
src/components/ui/card.tsx
Normal 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 };
|
||||
414
src/lib/hooks/useDashboard.ts
Normal file
414
src/lib/hooks/useDashboard.ts
Normal 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
83
src/lib/types.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue