fix(refactor): Cleanup components for improved readability and consistency
This commit is contained in:
parent
1228beb59a
commit
1b0c2c59b8
10 changed files with 590 additions and 350 deletions
|
|
@ -1,5 +1,6 @@
|
|||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getWebContent, getWebContentLite } from '@/lib/utils/documents';
|
||||
import { Document } from '@langchain/core/documents';
|
||||
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { ChatOpenAI } from '@langchain/openai';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
|
|
@ -10,6 +11,7 @@ import {
|
|||
getCustomOpenaiModelName,
|
||||
} from '@/lib/config';
|
||||
import { ChatOllama } from '@langchain/ollama';
|
||||
import axios from 'axios';
|
||||
|
||||
interface Source {
|
||||
url: string;
|
||||
|
|
@ -24,56 +26,71 @@ interface WidgetProcessRequest {
|
|||
}
|
||||
|
||||
// Helper function to fetch content from a single source
|
||||
async function fetchSourceContent(source: Source): Promise<{ content: string; error?: string }> {
|
||||
async function fetchSourceContent(
|
||||
source: Source,
|
||||
): Promise<{ content: string; error?: string }> {
|
||||
try {
|
||||
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);
|
||||
const response = await axios.get(source.url, { transformResponse: [] });
|
||||
document = new Document({
|
||||
pageContent: response.data || '',
|
||||
metadata: { source: source.url },
|
||||
});
|
||||
}
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
content: '',
|
||||
error: `Failed to fetch content from ${source.url}`
|
||||
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'}`
|
||||
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 {
|
||||
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);
|
||||
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> {
|
||||
async function getLLMInstance(
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<BaseChatModel | null> {
|
||||
try {
|
||||
const chatModelProviders = await getAvailableChatModelProviders();
|
||||
|
||||
|
|
@ -89,12 +106,12 @@ async function getLLMInstance(provider: string, model: string): Promise<BaseChat
|
|||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -106,28 +123,32 @@ async function getLLMInstance(provider: string, model: string): Promise<BaseChat
|
|||
}
|
||||
|
||||
// Helper function to process the prompt with LLM
|
||||
async function processWithLLM(prompt: string, provider: string, model: string): Promise<string> {
|
||||
async function processWithLLM(
|
||||
prompt: string,
|
||||
provider: string,
|
||||
model: string,
|
||||
): Promise<string> {
|
||||
const llm = await getLLMInstance(provider, model);
|
||||
|
||||
|
||||
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 }
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -135,19 +156,21 @@ export async function POST(request: NextRequest) {
|
|||
if (!Array.isArray(body.sources) || body.sources.length === 0) {
|
||||
return NextResponse.json(
|
||||
{ error: 'At least one source URL is required' },
|
||||
{ status: 400 }
|
||||
{ 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))
|
||||
body.sources.map((source) => fetchSourceContent(source)),
|
||||
);
|
||||
|
||||
// Check for fetch errors
|
||||
const fetchErrors = sourceResults
|
||||
.map((result, index) => result.error ? `Source ${index + 1}: ${result.error}` : null)
|
||||
.map((result, index) =>
|
||||
result.error ? `Source ${index + 1}: ${result.error}` : null,
|
||||
)
|
||||
.filter(Boolean);
|
||||
|
||||
if (fetchErrors.length > 0) {
|
||||
|
|
@ -155,35 +178,40 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
|
||||
// Extract successful content
|
||||
const sourceContents = sourceResults.map(result => result.content);
|
||||
|
||||
const sourceContents = sourceResults.map((result) => result.content);
|
||||
|
||||
// If all sources failed, return error
|
||||
if (sourceContents.every(content => !content)) {
|
||||
if (sourceContents.every((content) => !content)) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch content from all sources' },
|
||||
{ status: 500 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Replace variables in prompt
|
||||
const processedPrompt = replacePromptVariables(body.prompt, sourceContents);
|
||||
|
||||
console.log('Processed prompt:', processedPrompt.substring(0, 200) + '...');
|
||||
|
||||
console.log('Processing prompt:', processedPrompt);
|
||||
|
||||
// Process with LLM
|
||||
try {
|
||||
const llmResponse = await processWithLLM(processedPrompt, body.provider, body.model);
|
||||
|
||||
const llmResponse = await processWithLLM(
|
||||
processedPrompt,
|
||||
body.provider,
|
||||
body.model,
|
||||
);
|
||||
|
||||
console.log('LLM response:', llmResponse);
|
||||
return NextResponse.json({
|
||||
content: llmResponse,
|
||||
success: true,
|
||||
sourcesFetched: sourceContents.filter(content => content).length,
|
||||
sourcesFetched: sourceContents.filter((content) => content).length,
|
||||
totalSources: body.sources.length,
|
||||
warnings: fetchErrors.length > 0 ? fetchErrors : undefined
|
||||
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
|
||||
|
||||
|
|
@ -193,24 +221,26 @@ export async function POST(request: NextRequest) {
|
|||
${processedPrompt}
|
||||
|
||||
## Sources Successfully Fetched
|
||||
${sourceContents.filter(content => content).length} of ${body.sources.length} sources
|
||||
${sourceContents.filter((content) => content).length} of ${body.sources.length} sources
|
||||
|
||||
${fetchErrors.length > 0 ? `## Source Errors\n${fetchErrors.join('\n')}` : ''}`;
|
||||
|
||||
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
|
||||
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 }
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,28 @@
|
|||
'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 {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
LayoutDashboard,
|
||||
Layers,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import WidgetConfigModal from '@/components/dashboard/WidgetConfigModal';
|
||||
import WidgetDisplay from '@/components/dashboard/WidgetDisplay';
|
||||
import { useDashboard } from '@/lib/hooks/useDashboard';
|
||||
import { Widget, WidgetConfig } from '@/lib/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const DashboardPage = () => {
|
||||
const {
|
||||
|
|
@ -24,7 +40,19 @@ const DashboardPage = () => {
|
|||
} = useDashboard();
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingWidget, setEditingWidget] = useState<Widget | null>(null); const handleAddWidget = () => {
|
||||
const [editingWidget, setEditingWidget] = useState<Widget | null>(null);
|
||||
const hasAutoRefreshed = useRef(false);
|
||||
|
||||
// Auto-refresh stale widgets when dashboard loads (only once)
|
||||
useEffect(() => {
|
||||
if (!isLoading && widgets.length > 0 && !hasAutoRefreshed.current) {
|
||||
hasAutoRefreshed.current = true;
|
||||
|
||||
refreshAllWidgets();
|
||||
}
|
||||
}, [isLoading, widgets, refreshAllWidgets]);
|
||||
|
||||
const handleAddWidget = () => {
|
||||
setEditingWidget(null);
|
||||
setShowAddModal(true);
|
||||
};
|
||||
|
|
@ -60,18 +88,18 @@ const DashboardPage = () => {
|
|||
};
|
||||
|
||||
const handleRefreshAll = () => {
|
||||
refreshAllWidgets();
|
||||
refreshAllWidgets(true);
|
||||
};
|
||||
|
||||
const handleExport = async () => {
|
||||
try {
|
||||
const configJson = await exportDashboard();
|
||||
await navigator.clipboard.writeText(configJson);
|
||||
// TODO: Add toast notification for success
|
||||
toast.success('Dashboard configuration copied to clipboard');
|
||||
console.log('Dashboard configuration copied to clipboard');
|
||||
} catch (error) {
|
||||
console.error('Export failed:', error);
|
||||
// TODO: Add toast notification for error
|
||||
toast.error('Failed to copy dashboard configuration');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -79,11 +107,11 @@ const DashboardPage = () => {
|
|||
try {
|
||||
const configJson = await navigator.clipboard.readText();
|
||||
await importDashboard(configJson);
|
||||
// TODO: Add toast notification for success
|
||||
toast.success('Dashboard configuration imported successfully');
|
||||
console.log('Dashboard configuration imported successfully');
|
||||
} catch (error) {
|
||||
console.error('Import failed:', error);
|
||||
// TODO: Add toast notification for error
|
||||
toast.error('Failed to import dashboard configuration');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -98,18 +126,20 @@ const DashboardPage = () => {
|
|||
<CardHeader>
|
||||
<CardTitle>Welcome to your Dashboard</CardTitle>
|
||||
<CardDescription>
|
||||
Create your first widget to get started with personalized information
|
||||
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.
|
||||
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
|
||||
<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"
|
||||
>
|
||||
|
|
@ -130,7 +160,7 @@ const DashboardPage = () => {
|
|||
<LayoutDashboard />
|
||||
<h1 className="text-3xl font-medium p-2">Dashboard</h1>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={handleRefreshAll}
|
||||
|
|
@ -139,7 +169,7 @@ const DashboardPage = () => {
|
|||
>
|
||||
<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"
|
||||
|
|
@ -151,7 +181,7 @@ const DashboardPage = () => {
|
|||
<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"
|
||||
|
|
@ -159,7 +189,7 @@ const DashboardPage = () => {
|
|||
>
|
||||
<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"
|
||||
|
|
@ -167,7 +197,7 @@ const DashboardPage = () => {
|
|||
>
|
||||
<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"
|
||||
|
|
@ -186,7 +216,9 @@ const DashboardPage = () => {
|
|||
<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>
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Loading dashboard...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : widgets.length === 0 ? (
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ 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, ''))
|
||||
.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;
|
||||
};
|
||||
|
|
@ -28,10 +28,10 @@ const removeThinkTags = (content: string): string => {
|
|||
return content.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
};
|
||||
|
||||
const ThinkTagProcessor = ({
|
||||
children,
|
||||
isOverlayMode = false
|
||||
}: {
|
||||
const ThinkTagProcessor = ({
|
||||
children,
|
||||
isOverlayMode = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isOverlayMode?: boolean;
|
||||
}) => {
|
||||
|
|
@ -111,9 +111,13 @@ interface MarkdownRendererProps {
|
|||
thinkOverlay?: boolean;
|
||||
}
|
||||
|
||||
const MarkdownRenderer = ({ content, className, thinkOverlay = false }: MarkdownRendererProps) => {
|
||||
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;
|
||||
|
|
@ -153,27 +157,27 @@ const MarkdownRenderer = ({ content, className, thinkOverlay = false }: Markdown
|
|||
{/* Think box when expanded - shows above markdown */}
|
||||
{thinkOverlay && thinkContent && showThinkBox && (
|
||||
<div className="mb-4">
|
||||
<ThinkBox
|
||||
content={thinkContent}
|
||||
<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
|
||||
'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
|
||||
|
|
@ -181,7 +185,10 @@ const MarkdownRenderer = ({ content, className, thinkOverlay = false }: Markdown
|
|||
className="absolute top-2 right-2 p-2 rounded-lg bg-black/20 dark:bg-white/20 backdrop-blur-sm opacity-30 hover:opacity-100 transition-opacity duration-200 group"
|
||||
title="Show thinking process"
|
||||
>
|
||||
<Brain size={16} className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors" />
|
||||
<Brain
|
||||
size={16}
|
||||
className="text-gray-700 dark:text-gray-300 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -273,10 +273,7 @@ const MessageTabs = ({
|
|||
{/* Answer Tab */}
|
||||
{activeTab === 'text' && (
|
||||
<div className="flex flex-col space-y-4 animate-fadeIn">
|
||||
<MarkdownRenderer
|
||||
content={parsedMessage}
|
||||
className="px-4"
|
||||
/>
|
||||
<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,14 @@
|
|||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { BookOpenText, Home, Search, SquarePen, Settings, LayoutDashboard } from 'lucide-react';
|
||||
import {
|
||||
BookOpenText,
|
||||
Home,
|
||||
Search,
|
||||
SquarePen,
|
||||
Settings,
|
||||
LayoutDashboard,
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useSelectedLayoutSegments } from 'next/navigation';
|
||||
import React, { useState, type ReactNode } from 'react';
|
||||
|
|
|
|||
|
|
@ -17,10 +17,11 @@ const ThinkBox = ({ content, expanded, onToggle }: ThinkBoxProps) => {
|
|||
}
|
||||
|
||||
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));
|
||||
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">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
'use client';
|
||||
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogPanel,
|
||||
DialogTitle,
|
||||
Transition,
|
||||
TransitionChild,
|
||||
} from '@headlessui/react';
|
||||
import { X, Plus, Trash2, Play, Save } from 'lucide-react';
|
||||
import { Fragment, useState, useEffect } from 'react';
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||||
|
|
@ -9,20 +15,28 @@ import ModelSelector from '@/components/MessageInputActions/ModelSelector';
|
|||
// Helper function to replace date/time variables in prompts on the client side
|
||||
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);
|
||||
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);
|
||||
const localDateTime = new Date(
|
||||
now.getTime() - now.getTimezoneOffset() * 60000,
|
||||
).toISOString();
|
||||
processedPrompt = processedPrompt.replace(
|
||||
/\{\{current_local_datetime\}\}/g,
|
||||
localDateTime,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return processedPrompt;
|
||||
};
|
||||
|
||||
|
|
@ -67,7 +81,10 @@ const WidgetConfigModal = ({
|
|||
|
||||
const [previewContent, setPreviewContent] = useState<string>('');
|
||||
const [isPreviewLoading, setIsPreviewLoading] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState<{ provider: string; model: string } | null>(null);
|
||||
const [selectedModel, setSelectedModel] = useState<{
|
||||
provider: string;
|
||||
model: string;
|
||||
} | null>(null);
|
||||
|
||||
// Update config when editingWidget changes
|
||||
useEffect(() => {
|
||||
|
|
@ -106,7 +123,7 @@ const WidgetConfigModal = ({
|
|||
// Update config when model selection changes
|
||||
useEffect(() => {
|
||||
if (selectedModel) {
|
||||
setConfig(prev => ({
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
provider: selectedModel.provider,
|
||||
model: selectedModel.model,
|
||||
|
|
@ -118,7 +135,7 @@ const WidgetConfigModal = ({
|
|||
if (!config.title.trim() || !config.prompt.trim()) {
|
||||
return; // TODO: Add proper validation feedback
|
||||
}
|
||||
|
||||
|
||||
onSave(config);
|
||||
onClose();
|
||||
};
|
||||
|
|
@ -129,8 +146,13 @@ const WidgetConfigModal = ({
|
|||
return;
|
||||
}
|
||||
|
||||
if (config.sources.length === 0 || config.sources.every(s => !s.url.trim())) {
|
||||
setPreviewContent('Please add at least one source URL before running preview.');
|
||||
if (
|
||||
config.sources.length === 0 ||
|
||||
config.sources.every((s) => !s.url.trim())
|
||||
) {
|
||||
setPreviewContent(
|
||||
'Please add at least one source URL before running preview.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -145,7 +167,7 @@ const WidgetConfigModal = ({
|
|||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sources: config.sources.filter(s => s.url.trim()), // Only send sources with URLs
|
||||
sources: config.sources.filter((s) => s.url.trim()), // Only send sources with URLs
|
||||
prompt: processedPrompt,
|
||||
provider: config.provider,
|
||||
model: config.model,
|
||||
|
|
@ -153,40 +175,44 @@ const WidgetConfigModal = ({
|
|||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
|
||||
if (result.success) {
|
||||
setPreviewContent(result.content);
|
||||
} else {
|
||||
setPreviewContent(`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`);
|
||||
setPreviewContent(
|
||||
`**Preview Error:** ${result.error || 'Unknown error occurred'}\n\n${result.content || ''}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Preview error:', error);
|
||||
setPreviewContent(`**Network Error:** Failed to connect to the preview service.\n\n${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
setPreviewContent(
|
||||
`**Network Error:** Failed to connect to the preview service.\n\n${error instanceof Error ? error.message : 'Unknown error'}`,
|
||||
);
|
||||
} finally {
|
||||
setIsPreviewLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addSource = () => {
|
||||
setConfig(prev => ({
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
sources: [...prev.sources, { url: '', type: 'Web Page' }]
|
||||
sources: [...prev.sources, { url: '', type: 'Web Page' }],
|
||||
}));
|
||||
};
|
||||
|
||||
const removeSource = (index: number) => {
|
||||
setConfig(prev => ({
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
sources: prev.sources.filter((_, i) => i !== index)
|
||||
sources: prev.sources.filter((_, i) => i !== index),
|
||||
}));
|
||||
};
|
||||
|
||||
const updateSource = (index: number, field: keyof Source, value: string) => {
|
||||
setConfig(prev => ({
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
sources: prev.sources.map((source, i) =>
|
||||
i === index ? { ...source, [field]: value } : source
|
||||
)
|
||||
sources: prev.sources.map((source, i) =>
|
||||
i === index ? { ...source, [field]: value } : source,
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
|
|
@ -241,7 +267,12 @@ const WidgetConfigModal = ({
|
|||
<input
|
||||
type="text"
|
||||
value={config.title}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, title: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
title: e.target.value,
|
||||
}))
|
||||
}
|
||||
className="w-full px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="Enter widget title..."
|
||||
/>
|
||||
|
|
@ -258,13 +289,21 @@ const WidgetConfigModal = ({
|
|||
<input
|
||||
type="url"
|
||||
value={source.url}
|
||||
onChange={(e) => updateSource(index, 'url', e.target.value)}
|
||||
onChange={(e) =>
|
||||
updateSource(index, 'url', e.target.value)
|
||||
}
|
||||
className="flex-1 px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<select
|
||||
value={source.type}
|
||||
onChange={(e) => updateSource(index, 'type', e.target.value as Source['type'])}
|
||||
onChange={(e) =>
|
||||
updateSource(
|
||||
index,
|
||||
'type',
|
||||
e.target.value as Source['type'],
|
||||
)
|
||||
}
|
||||
className="px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="Web Page">Web Page</option>
|
||||
|
|
@ -297,7 +336,12 @@ const WidgetConfigModal = ({
|
|||
</label>
|
||||
<textarea
|
||||
value={config.prompt}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, prompt: e.target.value }))}
|
||||
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..."
|
||||
|
|
@ -310,13 +354,14 @@ const WidgetConfigModal = ({
|
|||
Model & Provider
|
||||
</label>
|
||||
<ModelSelector
|
||||
selectedModel={selectedModel}
|
||||
setSelectedModel={setSelectedModel}
|
||||
truncateModelName={false}
|
||||
showModelName={true}
|
||||
/>
|
||||
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
|
||||
Select the AI model and provider to process your widget
|
||||
content
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
@ -330,12 +375,24 @@ const WidgetConfigModal = ({
|
|||
type="number"
|
||||
min="1"
|
||||
value={config.refreshFrequency}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, refreshFrequency: parseInt(e.target.value) || 1 }))}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
refreshFrequency: parseInt(e.target.value) || 1,
|
||||
}))
|
||||
}
|
||||
className="flex-1 px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
<select
|
||||
value={config.refreshUnit}
|
||||
onChange={(e) => setConfig(prev => ({ ...prev, refreshUnit: e.target.value as 'minutes' | 'hours' }))}
|
||||
onChange={(e) =>
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
refreshUnit: e.target.value as
|
||||
| 'minutes'
|
||||
| 'hours',
|
||||
}))
|
||||
}
|
||||
className="px-3 py-2 border border-light-200 dark:border-dark-200 rounded-md bg-light-primary dark:bg-dark-primary text-black dark:text-white focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value="minutes">Minutes</option>
|
||||
|
|
@ -360,11 +417,14 @@ const WidgetConfigModal = ({
|
|||
{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">
|
||||
|
||||
<div className="h-80 p-4 border border-light-200 dark:border-dark-200 rounded-md bg-light-secondary dark:bg-dark-secondary overflow-y-auto max-w-full">
|
||||
{previewContent ? (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
||||
<MarkdownRenderer thinkOverlay={true} content={previewContent} />
|
||||
<div className="prose prose-sm dark:prose-invert max-w-full">
|
||||
<MarkdownRenderer
|
||||
thinkOverlay={true}
|
||||
content={previewContent}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-black/50 dark:text-white/50 italic">
|
||||
|
|
@ -377,11 +437,36 @@ const WidgetConfigModal = ({
|
|||
<div className="text-xs text-black/70 dark:text-white/70">
|
||||
<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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
'use client';
|
||||
|
||||
import { RefreshCw, Edit, Trash2, Clock, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
RefreshCw,
|
||||
Edit,
|
||||
Trash2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import MarkdownRenderer from '@/components/MarkdownRenderer';
|
||||
import { useState } from 'react';
|
||||
|
|
@ -32,12 +40,17 @@ interface WidgetDisplayProps {
|
|||
onRefresh: (widgetId: string) => void;
|
||||
}
|
||||
|
||||
const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayProps) => {
|
||||
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));
|
||||
|
|
@ -61,16 +74,16 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
|
|||
<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
|
||||
<span
|
||||
className="text-xs text-gray-500 dark:text-gray-400"
|
||||
title={getRefreshFrequencyText()}
|
||||
>
|
||||
{formatLastUpdated(widget.lastUpdated)}
|
||||
</span>
|
||||
|
||||
|
||||
{/* Refresh button */}
|
||||
<button
|
||||
onClick={() => onRefresh(widget.id)}
|
||||
|
|
@ -78,9 +91,9 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
|
|||
className="p-1.5 hover:bg-light-secondary dark:hover:bg-dark-secondary rounded transition-colors disabled:opacity-50"
|
||||
title="Refresh Widget"
|
||||
>
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`text-gray-600 dark:text-gray-400 ${widget.isLoading ? 'animate-spin' : ''}`}
|
||||
<RefreshCw
|
||||
size={16}
|
||||
className={`text-gray-600 dark:text-gray-400 ${widget.isLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -95,14 +108,21 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
|
|||
</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" />
|
||||
<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>
|
||||
<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">
|
||||
<div className="prose prose-sm dark:prose-invert">
|
||||
<MarkdownRenderer content={widget.content} thinkOverlay={true} />
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -134,10 +154,15 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
|
|||
{/* Sources */}
|
||||
{widget.sources.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">Sources:</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">
|
||||
Sources:
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
{widget.sources.map((source, index) => (
|
||||
<div key={index} className="flex items-center space-x-2 text-xs">
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center space-x-2 text-xs"
|
||||
>
|
||||
<span className="inline-block w-2 h-2 bg-blue-500 rounded-full"></span>
|
||||
<span className="text-gray-600 dark:text-gray-300 truncate">
|
||||
{source.url}
|
||||
|
|
@ -160,7 +185,7 @@ const WidgetDisplay = ({ widget, onEdit, onDelete, onRefresh }: WidgetDisplayPro
|
|||
<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"
|
||||
|
|
|
|||
|
|
@ -21,7 +21,8 @@ interface CardTitleProps extends React.HTMLAttributes<HTMLHeadingElement> {
|
|||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface CardDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
interface CardDescriptionProps
|
||||
extends React.HTMLAttributes<HTMLParagraphElement> {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
|
@ -31,13 +32,13 @@ const Card = React.forwardRef<HTMLDivElement, CardProps>(
|
|||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
);
|
||||
Card.displayName = 'Card';
|
||||
|
||||
|
|
@ -50,7 +51,7 @@ const CardHeader = React.forwardRef<HTMLDivElement, CardHeaderProps>(
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
);
|
||||
CardHeader.displayName = 'CardHeader';
|
||||
|
||||
|
|
@ -60,27 +61,28 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, CardTitleProps>(
|
|||
ref={ref}
|
||||
className={cn(
|
||||
'text-2xl font-semibold leading-none tracking-tight',
|
||||
className
|
||||
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>
|
||||
)
|
||||
);
|
||||
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>(
|
||||
|
|
@ -88,7 +90,7 @@ const CardContent = React.forwardRef<HTMLDivElement, CardContentProps>(
|
|||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
);
|
||||
CardContent.displayName = 'CardContent';
|
||||
|
||||
|
|
@ -101,8 +103,15 @@ const CardFooter = React.forwardRef<HTMLDivElement, CardFooterProps>(
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
),
|
||||
);
|
||||
CardFooter.displayName = 'CardFooter';
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Widget,
|
||||
WidgetConfig,
|
||||
DashboardState,
|
||||
import {
|
||||
Widget,
|
||||
WidgetConfig,
|
||||
DashboardState,
|
||||
DashboardConfig,
|
||||
DASHBOARD_STORAGE_KEYS,
|
||||
WidgetCache
|
||||
WidgetCache,
|
||||
} from '@/lib/types';
|
||||
|
||||
// Helper function to request location permission and get user's location
|
||||
|
|
@ -31,7 +31,7 @@ const requestLocationPermission = async (): Promise<string | undefined> => {
|
|||
enableHighAccuracy: true,
|
||||
timeout: 10000, // 10 seconds timeout
|
||||
maximumAge: 300000, // 5 minutes cache
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
@ -43,20 +43,28 @@ const requestLocationPermission = async (): Promise<string | undefined> => {
|
|||
// Helper function to replace date/time variables in prompts on the client side
|
||||
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);
|
||||
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);
|
||||
const localDateTime = new Date(
|
||||
now.getTime() - now.getTimezoneOffset() * 60000,
|
||||
).toISOString();
|
||||
processedPrompt = processedPrompt.replace(
|
||||
/\{\{current_local_datetime\}\}/g,
|
||||
localDateTime,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return processedPrompt;
|
||||
};
|
||||
|
||||
|
|
@ -66,19 +74,19 @@ interface UseDashboardReturn {
|
|||
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>;
|
||||
|
||||
refreshAllWidgets: (forceRefresh?: boolean) => Promise<void>;
|
||||
|
||||
// Storage management
|
||||
exportDashboard: () => Promise<string>;
|
||||
importDashboard: (configJson: string) => Promise<void>;
|
||||
clearCache: () => void;
|
||||
|
||||
|
||||
// Settings
|
||||
updateSettings: (newSettings: Partial<DashboardConfig['settings']>) => void;
|
||||
}
|
||||
|
|
@ -103,13 +111,19 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
// Save widgets to localStorage whenever they change (but not on initial load)
|
||||
useEffect(() => {
|
||||
if (!state.isLoading) {
|
||||
localStorage.setItem(DASHBOARD_STORAGE_KEYS.WIDGETS, JSON.stringify(state.widgets));
|
||||
localStorage.setItem(
|
||||
DASHBOARD_STORAGE_KEYS.WIDGETS,
|
||||
JSON.stringify(state.widgets),
|
||||
);
|
||||
}
|
||||
}, [state.widgets, state.isLoading]);
|
||||
|
||||
// Save settings to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
localStorage.setItem(DASHBOARD_STORAGE_KEYS.SETTINGS, JSON.stringify(state.settings));
|
||||
localStorage.setItem(
|
||||
DASHBOARD_STORAGE_KEYS.SETTINGS,
|
||||
JSON.stringify(state.settings),
|
||||
);
|
||||
}, [state.settings]);
|
||||
|
||||
const loadDashboardData = useCallback(() => {
|
||||
|
|
@ -117,23 +131,27 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
// 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 => {
|
||||
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',
|
||||
};
|
||||
const savedSettings = localStorage.getItem(
|
||||
DASHBOARD_STORAGE_KEYS.SETTINGS,
|
||||
);
|
||||
const settings = savedSettings
|
||||
? JSON.parse(savedSettings)
|
||||
: {
|
||||
parallelLoading: true,
|
||||
autoRefresh: false,
|
||||
theme: 'auto',
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets,
|
||||
settings,
|
||||
|
|
@ -141,7 +159,7 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading dashboard data:', error);
|
||||
setState(prev => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
error: 'Failed to load dashboard data',
|
||||
isLoading: false,
|
||||
|
|
@ -159,27 +177,27 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
error: null,
|
||||
};
|
||||
|
||||
setState(prev => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: [...prev.widgets, newWidget],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateWidget = useCallback((id: string, config: WidgetConfig) => {
|
||||
setState(prev => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map(widget =>
|
||||
widgets: prev.widgets.map((widget) =>
|
||||
widget.id === id
|
||||
? { ...widget, ...config, id } // Preserve the ID
|
||||
: widget
|
||||
: widget,
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const deleteWidget = useCallback((id: string) => {
|
||||
setState(prev => ({
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.filter(widget => widget.id !== id),
|
||||
widgets: prev.widgets.filter((widget) => widget.id !== id),
|
||||
}));
|
||||
|
||||
// Also remove from cache
|
||||
|
|
@ -200,143 +218,160 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
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);
|
||||
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;
|
||||
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
|
||||
// Check cache first (unless forcing refresh)
|
||||
if (!forceRefresh && isWidgetCacheValid(widget)) {
|
||||
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 => ({
|
||||
const cachedData = cache[widget.id];
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map(w =>
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id
|
||||
? {
|
||||
...w,
|
||||
content: cachedData.content,
|
||||
lastUpdated: new Date(cachedData.lastFetched),
|
||||
}
|
||||
: w,
|
||||
),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id ? { ...w, isLoading: true, error: null } : w,
|
||||
),
|
||||
}));
|
||||
|
||||
try {
|
||||
// Check if prompt uses location variable and request permission if needed
|
||||
let location: string | undefined;
|
||||
if (widget.prompt.includes('{{location}}')) {
|
||||
location = await requestLocationPermission();
|
||||
}
|
||||
|
||||
// Replace date/time variables on the client side
|
||||
const processedPrompt = replaceDateTimeVariables(widget.prompt);
|
||||
|
||||
const response = await fetch('/api/dashboard/process-widget', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sources: widget.sources,
|
||||
prompt: processedPrompt,
|
||||
provider: widget.provider,
|
||||
model: widget.model,
|
||||
location,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
const now = new Date();
|
||||
|
||||
if (result.success) {
|
||||
// Update widget
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id
|
||||
? {
|
||||
...w,
|
||||
isLoading: false,
|
||||
content: result.content,
|
||||
lastUpdated: now,
|
||||
error: null,
|
||||
}
|
||||
: w,
|
||||
),
|
||||
}));
|
||||
|
||||
// Cache the result
|
||||
const cache = getWidgetCache();
|
||||
cache[id] = {
|
||||
content: result.content,
|
||||
lastFetched: now,
|
||||
expiresAt: getCacheExpiryTime(widget),
|
||||
};
|
||||
localStorage.setItem(
|
||||
DASHBOARD_STORAGE_KEYS.CACHE,
|
||||
JSON.stringify(cache),
|
||||
);
|
||||
} else {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id
|
||||
? {
|
||||
...w,
|
||||
isLoading: false,
|
||||
error: result.error || 'Failed to refresh widget',
|
||||
}
|
||||
: w,
|
||||
),
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) =>
|
||||
w.id === id
|
||||
? {
|
||||
...w,
|
||||
isLoading: false,
|
||||
error: result.error || 'Failed to refresh widget',
|
||||
error: 'Network error: Failed to refresh widget',
|
||||
}
|
||||
: w
|
||||
: 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]);
|
||||
},
|
||||
[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);
|
||||
const refreshAllWidgets = useCallback(
|
||||
async (forceRefresh = false) => {
|
||||
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, forceRefresh)),
|
||||
);
|
||||
} else {
|
||||
// Refresh widgets sequentially (force refresh)
|
||||
for (const widget of activeWidgets) {
|
||||
await refreshWidget(widget.id, forceRefresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state.widgets, state.settings.parallelLoading, refreshWidget]);
|
||||
},
|
||||
[state.widgets, state.settings.parallelLoading, refreshWidget],
|
||||
);
|
||||
|
||||
const exportDashboard = useCallback(async (): Promise<string> => {
|
||||
const dashboardConfig: DashboardConfig = {
|
||||
|
|
@ -349,45 +384,57 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
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');
|
||||
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'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 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 },
|
||||
}));
|
||||
}, []);
|
||||
const updateSettings = useCallback(
|
||||
(newSettings: Partial<DashboardConfig['settings']>) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
settings: { ...prev.settings, ...newSettings },
|
||||
}));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
// State
|
||||
|
|
@ -395,19 +442,19 @@ export const useDashboard = (): UseDashboardReturn => {
|
|||
isLoading: state.isLoading,
|
||||
error: state.error,
|
||||
settings: state.settings,
|
||||
|
||||
|
||||
// Widget management
|
||||
addWidget,
|
||||
updateWidget,
|
||||
deleteWidget,
|
||||
refreshWidget,
|
||||
refreshAllWidgets,
|
||||
|
||||
|
||||
// Storage management
|
||||
exportDashboard,
|
||||
importDashboard,
|
||||
clearCache,
|
||||
|
||||
|
||||
// Settings
|
||||
updateSettings,
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue