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 { 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">
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
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