Merge pull request #2 from boarder2/simple_agent

Simple agent
This commit is contained in:
Willie Zutz 2025-08-17 13:40:43 -06:00 committed by GitHub
commit bb8b143bb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
101 changed files with 9808 additions and 6138 deletions

View file

@ -113,7 +113,7 @@ When working on this codebase, you might need to:
- Ask for clarification when requirements are unclear
- Do not add dependencies unless explicitly requested
- Only make changes relevant to the specific task
- Do not create test files or run the application unless requested
- **Do not create test files or run the application unless requested**
- Prioritize existing patterns and architectural decisions
- Use the established component structure and styling patterns
@ -143,3 +143,13 @@ When working on this codebase, you might need to:
- Use try/catch blocks for async operations
- Return structured error responses from API routes
## Available Tools and Help
- You can use the context7 tool to get help using the following identifiers for libraries used in this project
- `/langchain-ai/langchainjs` for LangChain
- `/langchain-ai/langgraphjs` for LangGraph
- `/quantizor/markdown-to-jsx` for Markdown to JSX conversion
- `/context7/headlessui_com` for Headless UI components
- `/tailwindlabs/tailwindcss.com` for Tailwind CSS documentation
- `/vercel/next.js` for Next.js documentation

View file

@ -50,10 +50,6 @@ Want to know more about its architecture and how it works? You can read it [here
- **All Mode:** Searches the entire web to find the best results.
- **Local Research Mode:** Research and interact with local files with citations.
- **Chat Mode:** Have a truly creative conversation without web search.
- **Academic Search Mode:** Finds articles and papers, ideal for academic research.
- **YouTube Search Mode:** Finds YouTube videos based on the search query.
- **Wolfram Alpha Search Mode:** Answers queries that need calculations or data analysis using Wolfram Alpha.
- **Reddit Search Mode:** Searches Reddit for discussions and opinions related to the query.
- **Current Information:** Some search tools might give you outdated info because they use data from crawling bots and convert them into embeddings and store them in a index. Unlike them, Perplexica uses SearxNG, a metasearch engine to get the results and rerank and get the most relevant source out of it, ensuring you always get the latest information without the overhead of daily data updates.
- **API**: Integrate Perplexica into your existing applications and make use of its capibilities.
@ -178,22 +174,6 @@ When running Perplexica behind a reverse proxy (like Nginx, Apache, or Traefik),
This ensures that OpenSearch descriptions, browser integrations, and all URLs work properly when accessing Perplexica through your reverse proxy.
## One-Click Deployment
[![Deploy to Sealos](https://raw.githubusercontent.com/labring-actions/templates/main/Deploy-on-Sealos.svg)](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
[![Deploy to RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploylobe.svg)](https://repocloud.io/details/?app_id=267)
[![Run on ClawCloud](https://raw.githubusercontent.com/ClawCloud/Run-Template/refs/heads/main/Run-on-ClawCloud.svg)](https://template.run.claw.cloud/?referralCode=U11MRQ8U9RM4&openapp=system-fastdeploy%3FtemplateName%3Dperplexica)
## Upcoming Features
- [x] Add settings page
- [x] Adding support for local LLMs
- [x] History Saving features
- [x] Introducing various Focus Modes
- [x] Adding API support
- [x] Adding Discover
- [ ] Finalizing Copilot Mode
## Fork Improvements
This fork adds several enhancements to the original Perplexica project:
@ -217,10 +197,6 @@ This fork adds several enhancements to the original Perplexica project:
- ✅ OpenSearch support with dynamic XML generation
- Added BASE_URL config to support reverse proxy deployments
- Added autocomplete functionality proxied to SearxNG
- ✅ Enhanced Reddit focus mode to work around SearxNG limitations
- ✅ Enhanced Balance mode that uses a headless web browser to retrieve web content and use relevant excerpts to enhance responses
- ✅ Adds Agent mode that uses the full content of web pages to answer queries and an agentic flow to intelligently answer complex queries with accuracy
- See the [README.md](docs/architecture/README.md) in the docs architecture directory for more info
- ✅ Query-based settings override for browser search engine integration
- Automatically applies user's saved optimization mode and AI model preferences when accessing via URL with `q` parameter
- Enables seamless browser search bar integration with personalized settings
@ -240,6 +216,25 @@ This fork adds several enhancements to the original Perplexica project:
- Configurable via settings UI with collapsible provider interface for better organization
- API support with `include_hidden` parameter for administrative access
### Unique Features
- ✅ **Agent Mode**: A new mode that uses a headless web browser to retrieve web content and use relevant excerpts to enhance responses.
- Automatically extracts relevant information from web pages
- Provides more accurate and contextually rich answers
- Ideal for complex queries requiring detailed information
- ✅ **Dashboard Widgets**: Create customizable AI-powered widgets for personalized information displays.
- Build widgets that combine web content with AI processing using custom prompts
- Support for multiple data sources (web pages, HTTP endpoints) with automatic content extraction
- Configurable refresh intervals (minutes/hours) for keeping information current
- Real-time preview system to test widget output before saving
- Automatic refresh of stale widgets when navigating to dashboard
- ✅ **Observability**: Built-in support for tracing and monitoring LLM calls using Langfuse or LangSmith.
- See [Tracing LLM Calls in Perplexica](docs/installation/TRACING.md) for more details.
- ✅ **Firefox AI Integration**: Enhanced support for Firefox users with tailored features and optimizations when Firefox AI prompts are detected.
### Bug Fixes
- ✅ Improved history rewriting

View file

@ -0,0 +1,43 @@
# Tracing LLM Calls in Perplexica
Perplexica supports tracing all LangChain and LangGraph LLM calls for debugging, analytics, and prompt transparency. You can use either Langfuse (self-hosted, private, or cloud) or LangSmith (cloud, by LangChain) for tracing.
## Langfuse Tracing (Recommended for Private/Self-Hosted)
Langfuse is an open-source, self-hostable observability platform for LLM applications. It allows you to trace prompts, completions, and tool calls **privately**—no data leaves your infrastructure if you self-host.
### Setup
1. **Deploy Langfuse**
- See: [Langfuse Self-Hosting Guide](https://langfuse.com/docs/self-hosting)
- You can also use the Langfuse Cloud if you prefer.
2. **Configure Environment Variables**
- Add the following to your environment variables in docker-compose or your deployment environment:
```env
LANGFUSE_PUBLIC_KEY=your-public-key
LANGFUSE_SECRET_KEY=your-secret-key
LANGFUSE_BASE_URL=https://your-langfuse-instance.com
```
- These are required for the tracing integration to work. If not set, tracing is disabled gracefully.
3. **Run Perplexica**
- All LLM and agent calls will be traced automatically. You can view traces in your Langfuse dashboard.
## LangSmith Tracing (Cloud by LangChain)
Perplexica also supports tracing via [LangSmith](https://smith.langchain.com/), the official observability platform by LangChain.
- To enable LangSmith, follow the official guide: [LangSmith Observability Docs](https://docs.smith.langchain.com/observability)
- Set the required environment variables as described in their documentation.
**LangSmith is a managed cloud service.**
---
For more details on tracing, see the respective documentation:
- [Langfuse Documentation](https://langfuse.com/docs)
- [LangSmith Observability](https://docs.smith.langchain.com/observability)

1755
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
{
"name": "perplexica-frontend",
"version": "1.11.0-rc2",
"version": "2.0.0",
"license": "MIT",
"author": "ItzCrazyKns",
"author": "Boarder2",
"scripts": {
"dev": "next dev --turbopack",
"build": "npm run db:push && next build",
@ -26,7 +26,6 @@
"@langchain/openai": "^0.5.12",
"@langchain/textsplitters": "^0.1.0",
"@mozilla/readability": "^0.6.0",
"@tailwindcss/typography": "^0.5.12",
"@types/react-syntax-highlighter": "^15.5.13",
"@xenova/transformers": "^2.17.2",
"axios": "^1.8.3",
@ -40,7 +39,9 @@
"jsdom": "^26.1.0",
"jspdf": "^3.0.1",
"langchain": "^0.3.26",
"langfuse-langchain": "^3.38.4",
"lucide-react": "^0.525.0",
"luxon": "^3.7.1",
"mammoth": "^1.9.1",
"markdown-to-jsx": "^7.7.2",
"next": "^15.2.2",
@ -49,6 +50,7 @@
"playwright": "^1.52.0",
"react": "^19",
"react-dom": "^19",
"react-grid-layout": "^1.5.2",
"react-syntax-highlighter": "^15.6.1",
"react-text-to-speech": "^2.1.2",
"react-textarea-autosize": "^8.5.3",
@ -59,21 +61,25 @@
"zod": "^3.22.4"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@tailwindcss/typography": "^0.5.16",
"@types/better-sqlite3": "^7.6.12",
"@types/html-to-text": "^9.0.4",
"@types/jsdom": "^21.1.7",
"@types/jspdf": "^2.0.0",
"@types/luxon": "^3.6.2",
"@types/node": "^20",
"@types/pdf-parse": "^1.1.4",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-grid-layout": "^1.3.5",
"autoprefixer": "^10.0.1",
"drizzle-kit": "^0.30.5",
"eslint": "^8",
"eslint-config-next": "14.1.4",
"postcss": "^8",
"prettier": "^3.2.5",
"tailwindcss": "^3.3.0",
"tailwindcss": "^4.0.0",
"typescript": "^5"
}
}

View file

@ -1,6 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

View file

@ -56,6 +56,11 @@ type Body = {
type ModelStats = {
modelName: string;
responseTime?: number;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
};
const handleEmitterEvents = async (
@ -108,7 +113,7 @@ const handleEmitterEvents = async (
writer.write(
encoder.encode(
JSON.stringify({
type: 'message',
type: 'response',
data: parsedData.data,
messageId: aiMessageId,
}) + '\n',
@ -138,20 +143,23 @@ const handleEmitterEvents = async (
);
sources = parsedData.data;
} else if (parsedData.type === 'tool_call') {
// Handle tool call events - stream them directly to the client AND accumulate for database
writer.write(
encoder.encode(
JSON.stringify({
type: 'tool_call',
data: parsedData.data,
messageId: aiMessageId,
}) + '\n',
),
);
// Add tool call content to the received message for database storage
recievedMessage += parsedData.data.content;
}
});
stream.on('agent_action', (data) => {
writer.write(
encoder.encode(
JSON.stringify({
type: 'agent_action',
data: data.data,
messageId: userMessageId,
}) + '\n',
),
);
});
let modelStats: ModelStats = {
modelName: '',
};

View file

@ -0,0 +1,266 @@
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, SystemMessage } from '@langchain/core/messages';
import { getAvailableChatModelProviders } from '@/lib/providers';
import {
getCustomOpenaiApiKey,
getCustomOpenaiApiUrl,
getCustomOpenaiModelName,
} from '@/lib/config';
import { ChatOllama } from '@langchain/ollama';
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { allTools } from '@/lib/tools';
import { Source } from '@/lib/types/widget';
import { WidgetProcessRequest } from '@/lib/types/api';
import axios from 'axios';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
// 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
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: 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 using agentic workflow
async function processWithLLM(
prompt: string,
provider: string,
model: string,
tool_names?: string[],
): Promise<string> {
const llm = await getLLMInstance(provider, model);
if (!llm) {
throw new Error(`Invalid or unavailable model: ${provider}/${model}`);
}
// Filter tools based on tool_names parameter
const tools =
tool_names && tool_names.length > 0
? allTools.filter((tool) => tool_names.includes(tool.name))
: [];
// Create the React agent with tools
const agent = createReactAgent({
llm,
tools,
});
// Invoke the agent with the prompt
const response = await agent.invoke(
{
messages: [
//new SystemMessage({ content: `You have the following tools available: ${tools.map(tool => tool.name).join(', ')} use them as necessary to complete the task.` }),
new HumanMessage({ content: prompt }),
],
},
{
recursionLimit: 15, // Limit recursion depth to prevent infinite loops
...getLangfuseCallbacks(),
},
);
// Extract the final response content
const lastMessage = response.messages[response.messages.length - 1];
return lastMessage.content as string;
}
export async function POST(request: NextRequest) {
try {
const body: WidgetProcessRequest = await request.json();
// Validate required fields
if (!body.prompt || !body.provider || !body.model) {
return NextResponse.json(
{ error: 'Missing required fields: prompt, provider, model' },
{ status: 400 },
);
}
const sources = body.sources;
let sourceContents: string[] = [];
let fetchErrors: string[] = [];
let processedPrompt = body.prompt;
let sourcesFetched = 0;
let totalSources = sources ? sources.length : 0;
if (sources && sources.length > 0) {
// Fetch content from all sources
console.log(`Processing widget with ${sources.length} source(s)`);
const sourceResults = await Promise.all(
sources.map((source) => fetchSourceContent(source)),
);
// Check for fetch errors
fetchErrors = sourceResults
.map((result, index) =>
result.error ? `Source ${index + 1}: ${result.error}` : null,
)
.filter((msg): msg is string => Boolean(msg));
if (fetchErrors.length > 0) {
console.warn('Some sources failed to fetch:', fetchErrors);
}
// Extract successful content
sourceContents = sourceResults.map((result) => result.content);
sourcesFetched = sourceContents.filter((content) => content).length;
// If all sources failed, return error
if (
sourceContents.length > 0 &&
sourceContents.every((content) => !content)
) {
return NextResponse.json(
{ error: 'Failed to fetch content from all sources' },
{ status: 500 },
);
}
// Replace variables in prompt
processedPrompt = replacePromptVariables(body.prompt, sourceContents);
}
console.log('Processing prompt:', processedPrompt);
// Process with LLM
try {
const llmResponse = await processWithLLM(
processedPrompt,
body.provider,
body.model,
body.tool_names,
);
console.log('LLM response:', llmResponse);
return NextResponse.json({
content: llmResponse,
success: true,
sourcesFetched,
totalSources,
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
${sourcesFetched} of ${totalSources} 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,
totalSources,
});
}
} catch (error) {
console.error('Error processing widget:', error);
return NextResponse.json(
{ error: 'Internal server error' },
{ status: 500 },
);
}
}

View file

@ -0,0 +1,20 @@
import { NextResponse } from 'next/server';
import { allTools } from '@/lib/tools';
export async function GET() {
try {
// Map over all tools to extract name and description
const toolsList = allTools.map((tool) => ({
name: tool.name,
description: tool.description,
}));
return NextResponse.json(toolsList);
} catch (error) {
console.error('Error fetching tools:', error);
return NextResponse.json(
{ error: 'Failed to fetch available tools' },
{ status: 500 },
);
}
}

View file

@ -17,6 +17,7 @@ import { ChatOpenAI } from '@langchain/openai';
import { ChatOllama } from '@langchain/ollama';
import { z } from 'zod';
import { withStructuredOutput } from '@/lib/utils/structuredOutput';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
interface FileRes {
fileName: string;
@ -71,7 +72,9 @@ Generate topics that describe what this document is about, its domain, and key s
name: 'generate_topics',
});
const result = await structuredLlm.invoke(prompt);
const result = await structuredLlm.invoke(prompt, {
...getLangfuseCallbacks(),
});
console.log('Generated topics:', result.topics);
// Filename is included for context
return filename + ', ' + result.topics.join(', ');

View file

@ -1,7 +1,19 @@
const CACHE_TTL_MS = 1000 * 60 * 20; // 20 minutes
type CacheEntry = {
ts: number;
weather: any;
};
const weatherCache = new Map<string, CacheEntry>();
export const POST = async (req: Request) => {
try {
const body: { lat: number; lng: number; temperatureUnit: 'C' | 'F' } =
await req.json();
const body: {
lat: number;
lng: number;
measureUnit: 'Imperial' | 'Metric';
} = await req.json();
if (!body.lat || !body.lng) {
return Response.json(
@ -12,8 +24,20 @@ export const POST = async (req: Request) => {
);
}
const cacheKey = `${body.lat.toFixed(4)},${body.lng.toFixed(4)},${body.measureUnit}`;
// Return cached entry if fresh
const cached = weatherCache.get(cacheKey);
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
console.log('Returning cached weather data for ', cacheKey);
return Response.json(cached.weather);
}
console.log('Fetching new weather data for ', cacheKey);
const res = await fetch(
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${body.temperatureUnit === 'C' ? '' : '&temperature_unit=fahrenheit'}`,
`https://api.open-meteo.com/v1/forecast?latitude=${body.lat}&longitude=${body.lng}&current=weather_code,temperature_2m,is_day,relative_humidity_2m,wind_speed_10m&timezone=auto${
body.measureUnit === 'Metric' ? '' : '&temperature_unit=fahrenheit'
}${body.measureUnit === 'Metric' ? '' : '&wind_speed_unit=mph'}`,
);
const data = await res.json();
@ -35,13 +59,15 @@ export const POST = async (req: Request) => {
windSpeed: number;
icon: string;
temperatureUnit: 'C' | 'F';
windSpeedUnit: 'm/s' | 'mph';
} = {
temperature: data.current.temperature_2m,
condition: '',
humidity: data.current.relative_humidity_2m,
windSpeed: data.current.wind_speed_10m,
icon: '',
temperatureUnit: body.temperatureUnit,
temperatureUnit: body.measureUnit === 'Metric' ? 'C' : 'F',
windSpeedUnit: body.measureUnit === 'Metric' ? 'm/s' : 'mph',
};
const code = data.current.weather_code;
@ -152,6 +178,13 @@ export const POST = async (req: Request) => {
break;
}
// store in cache
try {
weatherCache.set(cacheKey, { ts: Date.now(), weather });
} catch (e) {
// ignore cache failures
}
return Response.json(weather);
} catch (err) {
console.error('An error occurred while getting home widgets', err);

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

@ -0,0 +1,283 @@
'use client';
import {
Plus,
RefreshCw,
Download,
Upload,
LayoutDashboard,
Layers,
List,
} from 'lucide-react';
import { useState, useEffect, useRef, useMemo } from 'react';
import { Responsive, WidthProvider } from 'react-grid-layout';
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/widget';
import { DASHBOARD_CONSTRAINTS } from '@/lib/constants/dashboard';
import { toast } from 'sonner';
const ResponsiveGridLayout = WidthProvider(Responsive);
const DashboardPage = () => {
const {
widgets,
isLoading,
addWidget,
updateWidget,
deleteWidget,
refreshWidget,
refreshAllWidgets,
exportDashboard,
importDashboard,
settings,
updateSettings,
getLayouts,
updateLayouts,
} = useDashboard();
const [showAddModal, setShowAddModal] = useState(false);
const [editingWidget, setEditingWidget] = useState<Widget | null>(null);
const hasAutoRefreshed = useRef(false);
// Memoize the ResponsiveGridLayout to prevent re-renders
const ResponsiveGrid = useMemo(() => ResponsiveGridLayout, []);
// 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);
};
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(true);
};
const handleExport = async () => {
try {
const configJson = await exportDashboard();
await navigator.clipboard.writeText(configJson);
toast.success('Dashboard configuration copied to clipboard');
console.log('Dashboard configuration copied to clipboard');
} catch (error) {
console.error('Export failed:', error);
toast.error('Failed to copy dashboard configuration');
}
};
const handleImport = async () => {
try {
const configJson = await navigator.clipboard.readText();
await importDashboard(configJson);
toast.success('Dashboard configuration imported successfully');
console.log('Dashboard configuration imported successfully');
} catch (error) {
console.error('Import failed:', error);
toast.error('Failed to import dashboard configuration');
}
};
const handleToggleProcessingMode = () => {
updateSettings({ parallelLoading: !settings.parallelLoading });
};
// Handle layout changes from react-grid-layout
const handleLayoutChange = (layout: any, layouts: any) => {
updateLayouts(layouts);
};
// Memoize grid children to prevent unnecessary re-renders
const gridChildren = useMemo(() => {
return widgets.map((widget) => (
<div key={widget.id}>
<WidgetDisplay
widget={widget}
onEdit={handleEditWidget}
onDelete={handleDeleteWidget}
onRefresh={handleRefreshWidget}
/>
</div>
));
}, [widgets]);
// 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-fg/60 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-accent text-white rounded hover:bg-accent-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-surface-2 rounded-lg transition duration-200"
title="Refresh All Widgets"
>
<RefreshCw size={18} />
</button>
<button
onClick={handleToggleProcessingMode}
className="p-2 hover:bg-surface-2 rounded-lg transition duration-200"
title={`Switch to ${settings.parallelLoading ? 'Sequential' : 'Parallel'} Processing`}
>
{settings.parallelLoading ? (
<Layers size={18} />
) : (
<List size={18} />
)}
</button>
<button
onClick={handleExport}
className="p-2 hover:bg-surface-2 rounded-lg transition duration-200"
title="Export Dashboard Configuration"
>
<Download size={18} />
</button>
<button
onClick={handleImport}
className="p-2 hover:bg-surface-2 rounded-lg transition duration-200"
title="Import Dashboard Configuration"
>
<Upload size={18} />
</button>
<button
onClick={handleAddWidget}
className="p-2 bg-accent hover:bg-accent-700 rounded-lg transition duration-200"
title="Add New Widget"
>
<Plus size={18} />
</button>
</div>
</div>
<hr className="border-t 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 mx-auto mb-4"></div>
<p className="text-fg/60">Loading dashboard...</p>
</div>
</div>
) : widgets.length === 0 ? (
<EmptyDashboard />
) : (
<ResponsiveGrid
className="layout"
layouts={getLayouts()}
breakpoints={DASHBOARD_CONSTRAINTS.GRID_BREAKPOINTS}
cols={DASHBOARD_CONSTRAINTS.GRID_COLUMNS}
rowHeight={DASHBOARD_CONSTRAINTS.GRID_ROW_HEIGHT}
margin={DASHBOARD_CONSTRAINTS.GRID_MARGIN}
containerPadding={DASHBOARD_CONSTRAINTS.GRID_CONTAINER_PADDING}
onLayoutChange={handleLayoutChange}
isDraggable={true}
isResizable={true}
compactType="vertical"
preventCollision={false}
draggableHandle=".widget-drag-handle"
>
{gridChildren}
</ResponsiveGrid>
)}
</div>
{/* Widget Configuration Modal */}
<WidgetConfigModal
isOpen={showAddModal}
onClose={handleCloseModal}
onSave={handleSaveWidget}
editingWidget={editingWidget}
/>
</div>
);
};
export default DashboardPage;

View file

@ -50,7 +50,7 @@ const Page = () => {
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
className="w-8 h-8 text-fg/20 fill-fg/30 animate-spin"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@ -73,7 +73,7 @@ const Page = () => {
<Search />
<h1 className="text-3xl font-medium p-2">Discover</h1>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
<hr className="border-t border-surface-2 my-4 w-full" />
</div>
<div className="grid lg:grid-cols-3 sm:grid-cols-2 grid-cols-1 gap-4 pb-28 lg:pb-8 w-full justify-items-center lg:justify-items-start">
@ -82,7 +82,7 @@ const Page = () => {
<Link
href={`/?q=Summary: ${item.url}`}
key={i}
className="max-w-sm rounded-lg overflow-hidden bg-light-secondary dark:bg-dark-secondary hover:-translate-y-[1px] transition duration-200"
className="max-w-sm rounded-lg overflow-hidden bg-surface border border-surface-2 hover:-translate-y-[1px] transition duration-200"
target="_blank"
>
<img
@ -95,10 +95,10 @@ const Page = () => {
alt={item.title}
/>
<div className="px-6 py-4">
<div className="font-bold text-lg mb-2">
<div className="font-bold text-lg mb-2 text-fg">
{item.title.slice(0, 100)}...
</div>
<p className="text-black-70 dark:text-white/70 text-sm">
<p className="text-fg/70 text-sm">
{item.content.slice(0, 100)}...
</p>
</div>

View file

@ -1,6 +1,41 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* React Grid Layout styles */
@import 'react-grid-layout/css/styles.css';
@import 'react-resizable/css/styles.css';
@import 'tailwindcss';
@plugin '@tailwindcss/typography';
/* Make dark: variants depend on html.dark or [data-theme="dark"] */
@custom-variant dark (&:where(.dark, .dark *, [data-theme='dark'], [data-theme='dark'] *));
/* Theme tokens */
@theme {
/* Base palette (light by default) */
--color-bg: oklch(0.98 0 0); /* canvas/background */
--color-fg: oklch(0.21 0 0); /* text */
--color-accent-600: var(--color-blue-600);
--color-accent-700: var(--color-blue-700);
--color-accent-500: var(--color-blue-500);
--color-surface: color-mix(in oklch, var(--color-bg), black 6%);
--color-surface-2: color-mix(in oklch, var(--color-bg), black 10%);
/* Shorthands that Tailwind maps into utilities */
--color-accent: var(--color-accent-600);
}
:root {
color-scheme: light;
}
[data-theme='dark'] {
color-scheme: dark;
--color-bg: oklch(0.16 0 0);
--color-fg: oklch(0.95 0 0);
--color-surface: color-mix(in oklch, var(--color-bg), white 8%);
--color-surface-2: color-mix(in oklch, var(--color-bg), white 12%);
}
/* Custom theme overrides are applied via CSS variables on :root by ThemeController */
@layer base {
.overflow-hidden-scrollable {
@ -10,6 +45,39 @@
.overflow-hidden-scrollable::-webkit-scrollbar {
display: none;
}
html,
body {
min-height: 100dvh;
}
button:not(:disabled),
[role='button']:not(:disabled),
input[type='button']:not(:disabled),
input[type='submit']:not(:disabled),
input[type='reset']:not(:disabled) {
cursor: pointer;
color: var(--color-fg);
}
button:not(:disabled):hover,
[role='button']:not(:disabled):hover,
input[type='button']:not(:disabled):hover,
input[type='submit']:not(:disabled):hover,
input[type='reset']:not(:disabled):hover {
color: var(--color-accent);
}
input[type='text']:focus,
textarea:focus {
border-color: var(--color-accent);
outline: none;
border: 1px solid var(--color-accent);
}
a:hover {
color: var(--color-accent);
}
}
@layer utilities {
@ -33,3 +101,55 @@
font-size: 16px !important;
}
}
/* Utilities are auto-generated from @theme tokens */
@layer utilities {
/* Backwards-compat for prior custom palette names */
.bg-light-primary {
background-color: var(--color-bg);
}
.dark .bg-dark-primary {
background-color: var(--color-bg);
}
.bg-light-secondary {
background-color: var(--color-surface);
}
.dark .bg-dark-secondary {
background-color: var(--color-surface);
}
.bg-light-100 {
background-color: var(--color-surface-2);
}
.dark .bg-dark-100 {
background-color: var(--color-surface-2);
}
.hover\:bg-light-200:hover {
background-color: var(--color-surface-2);
}
.dark .hover\:bg-dark-200:hover {
background-color: var(--color-surface-2);
}
.border-light-200 {
border-color: var(--color-surface-2);
}
.dark .border-dark-200 {
border-color: var(--color-surface-2);
}
/* Preferred token utilities */
.bg-bg {
background-color: var(--color-bg);
}
.bg-surface {
background-color: var(--color-surface);
}
.bg-surface-2 {
background-color: var(--color-surface-2);
}
.text-fg {
color: var(--color-fg);
}
.border-surface-2 {
border-color: var(--color-surface-2);
}
}

View file

@ -4,7 +4,7 @@ import './globals.css';
import { cn } from '@/lib/utils';
import Sidebar from '@/components/Sidebar';
import { Toaster } from 'sonner';
import ThemeProvider from '@/components/theme/Provider';
import ThemeController from '@/components/theme/Controller';
const montserrat = Montserrat({
weight: ['300', '400', '500', '700'],
@ -34,19 +34,19 @@ export default function RootLayout({
href="/api/opensearch"
/>
</head>
<body className={cn('h-full', montserrat.className)}>
<ThemeProvider>
<body className={cn('h-full bg-bg text-fg', montserrat.className)}>
<ThemeController>
<Sidebar>{children}</Sidebar>
<Toaster
toastOptions={{
unstyled: true,
classNames: {
toast:
'bg-light-primary dark:bg-dark-secondary dark:text-white/70 text-black-70 rounded-lg p-4 flex flex-row items-center space-x-2',
'bg-surface text-fg rounded-lg p-4 flex flex-row items-center space-x-2',
},
}}
/>
</ThemeProvider>
</ThemeController>
</body>
</html>
);

View file

@ -41,7 +41,7 @@ const Page = () => {
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
className="w-8 h-8 text-fg/20 fill-fg/30 animate-spin"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@ -63,13 +63,11 @@ const Page = () => {
<BookOpenText />
<h1 className="text-3xl font-medium p-2">Library</h1>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
<hr className="border-t border-surface-2 my-4 w-full" />
</div>
{chats.length === 0 && (
<div className="flex flex-row items-center justify-center min-h-screen">
<p className="text-black/70 dark:text-white/70 text-sm">
No chats found.
</p>
<p className="text-fg/70 text-sm">No chats found.</p>
</div>
)}
{chats.length > 0 && (
@ -78,20 +76,18 @@ const Page = () => {
<div
className={cn(
'flex flex-col space-y-4 py-6',
i !== chats.length - 1
? 'border-b border-white-200 dark:border-dark-200'
: '',
i !== chats.length - 1 ? 'border-b border-surface-2' : '',
)}
key={i}
>
<Link
href={`/c/${chat.id}`}
className="text-black dark:text-white lg:text-xl font-medium truncate transition duration-200 hover:text-[#24A0ED] dark:hover:text-[#24A0ED] cursor-pointer"
className="lg:text-xl font-medium truncate transition duration-200 cursor-pointer"
>
{chat.title}
</Link>
<div className="flex flex-row items-center justify-between w-full">
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 text-black/70 dark:text-white/70">
<div className="flex flex-row items-center space-x-1 lg:space-x-1.5 opacity-70">
<ClockIcon size={15} />
<p className="text-xs">
{formatTimeDifference(new Date(), chat.createdAt)} Ago

View file

@ -13,6 +13,10 @@ import {
RotateCcw,
ChevronDown,
ChevronRight,
Eye,
EyeOff,
Cloud,
LucideNewspaper,
} from 'lucide-react';
import { useEffect, useState, useRef } from 'react';
import { cn } from '@/lib/utils';
@ -63,7 +67,7 @@ const InputComponent = ({
<input
{...restProps}
className={cn(
'bg-light-secondary dark:bg-dark-secondary w-full px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
'bg-surface w-full px-3 py-2 flex items-center overflow-hidden rounded-lg text-sm',
isSaving && 'pr-10',
className,
)}
@ -71,10 +75,7 @@ const InputComponent = ({
/>
{isSaving && (
<div className="absolute right-3 top-1/2 -translate-y-1/2">
<Loader2
size={16}
className="animate-spin text-black/70 dark:text-white/70"
/>
<Loader2 size={16} className="animate-spin" />
</div>
)}
</div>
@ -96,17 +97,14 @@ const TextareaComponent = ({
<div className="relative">
<textarea
placeholder="Any special instructions for the LLM"
className="placeholder:text-sm text-sm w-full flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors"
className="placeholder:text-sm text-sm w-full flex items-center justify-between p-3 bg-surface rounded-lg hover:bg-surface-2 transition-colors"
rows={4}
onBlur={(e) => onSave?.(e.target.value)}
{...restProps}
/>
{isSaving && (
<div className="absolute right-3 top-3">
<Loader2
size={16}
className="animate-spin text-black/70 dark:text-white/70"
/>
<Loader2 size={16} className="animate-spin" />
</div>
)}
</div>
@ -124,7 +122,7 @@ const Select = ({
<select
{...restProps}
className={cn(
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
'bg-surface px-3 py-2 flex items-center overflow-hidden border border-surface-2 rounded-lg text-sm',
className,
)}
>
@ -169,16 +167,14 @@ const SettingsSection = ({
}, []);
return (
<div className="flex flex-col space-y-4 p-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200">
<div className="flex flex-col space-y-4 p-4 bg-surface rounded-xl border border-surface-2">
<div className="flex items-center gap-2">
<h2 className="text-black/90 dark:text-white/90 font-medium">
{title}
</h2>
<h2 className="font-medium">{title}</h2>
{tooltip && (
<div className="relative">
<button
ref={buttonRef}
className="p-1 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
className="p-1 rounded-full hover:bg-surface-2 transition duration-200"
onClick={() => setShowTooltip(!showTooltip)}
aria-label="Show section information"
>
@ -187,10 +183,10 @@ const SettingsSection = ({
{showTooltip && (
<div
ref={tooltipRef}
className="absolute z-10 left-6 top-0 w-96 rounded-md shadow-lg bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200"
className="absolute z-10 left-6 top-0 w-96 rounded-md shadow-lg bg-surface border border-surface-2"
>
<div className="py-2 px-3">
<div className="space-y-1 text-xs text-black dark:text-white">
<div className="space-y-1 text-xs">
{tooltip.split('\\n').map((line, index) => (
<div key={index}>{line}</div>
))}
@ -232,7 +228,11 @@ export default function SettingsPage() {
>(null);
const [isLoading, setIsLoading] = useState(true);
const [automaticSuggestions, setAutomaticSuggestions] = useState(true);
const [temperatureUnit, setTemperatureUnit] = useState<'C' | 'F'>('C');
const [showWeatherWidget, setShowWeatherWidget] = useState(true);
const [showNewsWidget, setShowNewsWidget] = useState(true);
const [measureUnit, setMeasureUnit] = useState<'Imperial' | 'Metric'>(
'Metric',
);
const [savingStates, setSavingStates] = useState<Record<string, boolean>>({});
const [contextWindowSize, setContextWindowSize] = useState(2048);
const [isCustomContextWindow, setIsCustomContextWindow] = useState(false);
@ -326,6 +326,10 @@ export default function SettingsPage() {
setAutomaticSuggestions(
localStorage.getItem('autoSuggestions') !== 'false', // default to true if not set
);
setShowWeatherWidget(
localStorage.getItem('showWeatherWidget') !== 'false',
);
setShowNewsWidget(localStorage.getItem('showNewsWidget') !== 'false');
const storedContextWindow = parseInt(
localStorage.getItem('ollamaContextWindow') ?? '2048',
);
@ -334,7 +338,9 @@ export default function SettingsPage() {
!predefinedContextSizes.includes(storedContextWindow),
);
setTemperatureUnit(localStorage.getItem('temperatureUnit')! as 'C' | 'F');
setMeasureUnit(
localStorage.getItem('measureUnit')! as 'Imperial' | 'Metric',
);
setIsLoading(false);
};
@ -556,8 +562,8 @@ export default function SettingsPage() {
localStorage.setItem('embeddingModel', value);
} else if (key === 'ollamaContextWindow') {
localStorage.setItem('ollamaContextWindow', value.toString());
} else if (key === 'temperatureUnit') {
localStorage.setItem('temperatureUnit', value.toString());
} else if (key === 'measureUnit') {
localStorage.setItem('measureUnit', value.toString());
}
} catch (err) {
console.error('Failed to save:', err);
@ -600,6 +606,39 @@ export default function SettingsPage() {
}
};
const handleProviderVisibilityToggle = async (
providerModels: Record<string, any>,
showAll: boolean,
) => {
const modelKeys = Object.keys(providerModels);
let updatedHiddenModels: string[];
if (showAll) {
// Show all models in this provider, remove all from hidden list
updatedHiddenModels = hiddenModels.filter(
(modelKey) => !modelKeys.includes(modelKey),
);
} else {
// Hide all models in this provider, add all to hidden list
const modelsToHide = modelKeys.filter(
(modelKey) => !hiddenModels.includes(modelKey),
);
updatedHiddenModels = [...hiddenModels, ...modelsToHide];
}
// Update local state immediately
setHiddenModels(updatedHiddenModels);
// Persist changes to backend
try {
await saveConfig('hiddenModels', updatedHiddenModels);
} catch (error) {
console.error('Failed to save hidden models:', error);
// Revert local state on error
setHiddenModels(hiddenModels);
}
};
const toggleProviderExpansion = (providerId: string) => {
setExpandedProviders((prev) => {
const newSet = new Set(prev);
@ -694,21 +733,21 @@ export default function SettingsPage() {
<div className="flex flex-col pt-4">
<div className="flex items-center space-x-2">
<Link href="/" className="lg:hidden">
<ArrowLeft className="text-black/70 dark:text-white/70" />
<ArrowLeft />
</Link>
<div className="flex flex-row space-x-0.5 items-center">
<SettingsIcon size={23} />
<h1 className="text-3xl font-medium p-2">Settings</h1>
</div>
</div>
<hr className="border-t border-[#2B2C2C] my-4 w-full" />
<hr className="border-t border-surface-2 my-4 w-full" />
</div>
{isLoading ? (
<div className="flex flex-row items-center justify-center min-h-[50vh]">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
className="w-8 h-8 text-surface-2 fill-surface animate-spin"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"
@ -728,29 +767,99 @@ export default function SettingsPage() {
<div className="flex flex-col space-y-6 pb-28 lg:pb-8">
<SettingsSection title="Preferences">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Theme
</p>
<p className="text-sm">Theme</p>
<ThemeSwitcher />
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Temperature Unit
</p>
<p className="text-sm">Home Page Widgets</p>
<div className="flex flex-col space-y-2">
<div className="flex items-center justify-between p-3 bg-surface rounded-lg hover:bg-surface-2 transition-colors">
<div className="flex items-center space-x-3">
<div className="p-2 bg-surface-2 rounded-lg">
<Cloud size={18} />
</div>
<div>
<p className="text-sm font-medium">Weather Widget</p>
<p className="text-xs mt-0.5">
Show or hide the weather widget on the home page
</p>
</div>
</div>
<Switch
checked={showWeatherWidget}
onChange={(checked) => {
setShowWeatherWidget(checked);
localStorage.setItem(
'showWeatherWidget',
checked.toString(),
);
}}
className={cn(
showWeatherWidget ? 'bg-accent' : 'bg-surface-2',
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
)}
>
<span
className={cn(
showWeatherWidget ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
)}
/>
</Switch>
</div>
<div className="flex items-center justify-between p-3 bg-surface rounded-lg hover:bg-surface-2 transition-colors">
<div className="flex items-center space-x-3">
<div className="p-2 bg-surface-2 rounded-lg">
<LucideNewspaper size={18} />
</div>
<div>
<p className="text-sm font-medium">News Widget</p>
<p className="text-xs mt-0.5">
Show or hide the news widget on the home page
</p>
</div>
</div>
<Switch
checked={showNewsWidget}
onChange={(checked) => {
setShowNewsWidget(checked);
localStorage.setItem(
'showNewsWidget',
checked.toString(),
);
}}
className={cn(
showNewsWidget ? 'bg-accent' : 'bg-surface-2',
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
)}
>
<span
className={cn(
showNewsWidget ? 'translate-x-6' : 'translate-x-1',
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
)}
/>
</Switch>
</div>
</div>
</div>
<div className="flex flex-col space-y-1">
<p className="text-sm">Measurement Units</p>
<Select
value={temperatureUnit ?? undefined}
value={measureUnit ?? undefined}
onChange={(e) => {
setTemperatureUnit(e.target.value as 'C' | 'F');
saveConfig('temperatureUnit', e.target.value);
setMeasureUnit(e.target.value as 'Imperial' | 'Metric');
saveConfig('measureUnit', e.target.value);
}}
options={[
{
label: 'Celsius',
value: 'C',
label: 'Metric',
value: 'Metric',
},
{
label: 'Fahrenheit',
value: 'F',
label: 'Imperial',
value: 'Imperial',
},
]}
/>
@ -759,19 +868,16 @@ export default function SettingsPage() {
<SettingsSection title="Automatic Search">
<div className="flex flex-col space-y-4">
<div className="flex items-center justify-between p-3 bg-light-secondary dark:bg-dark-secondary rounded-lg hover:bg-light-200 dark:hover:bg-dark-200 transition-colors">
<div className="flex items-center justify-between p-3 bg-surface rounded-lg hover:bg-surface-2 transition-colors">
<div className="flex items-center space-x-3">
<div className="p-2 bg-light-200 dark:bg-dark-200 rounded-lg">
<Layers3
size={18}
className="text-black/70 dark:text-white/70"
/>
<div className="p-2 bg-surface-2 rounded-lg">
<Layers3 size={18} />
</div>
<div>
<p className="text-sm text-black/90 dark:text-white/90 font-medium">
<p className="text-sm font-medium">
Automatic Suggestions
</p>
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
<p className="text-xs mt-0.5">
Automatically show related suggestions after responses
</p>
</div>
@ -783,9 +889,7 @@ export default function SettingsPage() {
saveConfig('automaticSuggestions', checked);
}}
className={cn(
automaticSuggestions
? 'bg-[#24A0ED]'
: 'bg-light-200 dark:bg-dark-200',
automaticSuggestions ? 'bg-accent' : 'bg-surface-2',
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none',
)}
>
@ -813,7 +917,7 @@ export default function SettingsPage() {
.map((prompt) => (
<div
key={prompt.id}
className="p-3 border border-light-secondary dark:border-dark-secondary rounded-md bg-light-100 dark:bg-dark-100"
className="p-3 border border-surface-2 rounded-md bg-surface-2"
>
{editingPrompt && editingPrompt.id === prompt.id ? (
<div className="space-y-3">
@ -829,7 +933,6 @@ export default function SettingsPage() {
})
}
placeholder="Prompt Name"
className="text-black dark:text-white bg-white dark:bg-dark-secondary"
/>
<Select
value={editingPrompt.type}
@ -843,7 +946,6 @@ export default function SettingsPage() {
{ value: 'system', label: 'System Prompt' },
{ value: 'persona', label: 'Persona Prompt' },
]}
className="text-black dark:text-white bg-white dark:bg-dark-secondary"
/>
<TextareaComponent
value={editingPrompt.content}
@ -856,19 +958,19 @@ export default function SettingsPage() {
})
}
placeholder="Prompt Content"
className="min-h-[100px] text-black dark:text-white bg-white dark:bg-dark-secondary"
className="min-h-[100px]"
/>
<div className="flex space-x-2 justify-end">
<button
onClick={() => setEditingPrompt(null)}
className="px-3 py-2 text-sm rounded-md bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 text-black/80 dark:text-white/80 flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md bg-surface hover:bg-surface-2 flex items-center gap-1.5"
>
<X size={16} />
Cancel
</button>
<button
onClick={handleAddOrUpdateSystemPrompt}
className="px-3 py-2 text-sm rounded-md bg-[#24A0ED] hover:bg-[#1f8cdb] text-white flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md flex items-center gap-1.5 bg-accent"
>
<Save size={16} />
Save
@ -878,11 +980,9 @@ export default function SettingsPage() {
) : (
<div className="flex justify-between items-start">
<div className="flex-grow">
<h4 className="font-semibold text-black/90 dark:text-white/90">
{prompt.name}
</h4>
<h4 className="font-semibold">{prompt.name}</h4>
<p
className="text-sm text-black/70 dark:text-white/70 mt-1 whitespace-pre-wrap overflow-hidden text-ellipsis"
className="text-sm mt-1 whitespace-pre-wrap overflow-hidden text-ellipsis"
style={{
maxHeight: '3.6em',
display: '-webkit-box',
@ -897,7 +997,7 @@ export default function SettingsPage() {
<button
onClick={() => setEditingPrompt({ ...prompt })}
title="Edit"
className="p-1.5 rounded-md hover:bg-light-200 dark:hover:bg-dark-200 text-black/70 dark:text-white/70"
className="p-1.5 rounded-md hover:bg-surface-2"
>
<Edit3 size={18} />
</button>
@ -906,7 +1006,7 @@ export default function SettingsPage() {
handleDeleteSystemPrompt(prompt.id)
}
title="Delete"
className="p-1.5 rounded-md hover:bg-light-200 dark:hover:bg-dark-200 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-500"
className="p-1.5 rounded-md hover:bg-surface-2 text-red-500 hover:text-red-600"
>
<Trash2 size={18} />
</button>
@ -916,7 +1016,7 @@ export default function SettingsPage() {
</div>
))}
{isAddingNewPrompt && newPromptType === 'system' && (
<div className="p-3 border border-dashed border-light-secondary dark:border-dark-secondary rounded-md space-y-3 bg-light-100 dark:bg-dark-100">
<div className="p-3 border border-dashed border-surface-2 rounded-md space-y-3 bg-surface-2">
<InputComponent
type="text"
value={newPromptName}
@ -924,7 +1024,6 @@ export default function SettingsPage() {
setNewPromptName(e.target.value)
}
placeholder="System Prompt Name"
className="text-black dark:text-white bg-white dark:bg-dark-secondary"
/>
<TextareaComponent
value={newPromptContent}
@ -932,7 +1031,7 @@ export default function SettingsPage() {
setNewPromptContent(e.target.value)
}
placeholder="System prompt content (e.g., '/nothink')"
className="min-h-[100px] text-black dark:text-white bg-white dark:bg-dark-secondary"
className="min-h-[100px]"
/>
<div className="flex space-x-2 justify-end">
<button
@ -942,14 +1041,14 @@ export default function SettingsPage() {
setNewPromptContent('');
setNewPromptType('system');
}}
className="px-3 py-2 text-sm rounded-md bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 text-black/80 dark:text-white/80 flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md bg-surface hover:bg-surface-2 flex items-center gap-1.5"
>
<X size={16} />
Cancel
</button>
<button
onClick={handleAddOrUpdateSystemPrompt}
className="px-3 py-2 text-sm rounded-md bg-[#24A0ED] hover:bg-[#1f8cdb] text-white flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md flex items-center gap-1.5 bg-accent"
>
<Save size={16} />
Add System Prompt
@ -963,7 +1062,7 @@ export default function SettingsPage() {
setIsAddingNewPrompt(true);
setNewPromptType('system');
}}
className="self-start px-3 py-2 text-sm rounded-md border border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 text-black/80 dark:text-white/80 flex items-center gap-1.5"
className="self-start px-3 py-2 text-sm rounded-md border border-surface-2 hover:bg-surface-2 flex items-center gap-1.5"
>
<PlusCircle size={18} /> Add System Prompt
</button>
@ -981,7 +1080,7 @@ export default function SettingsPage() {
.map((prompt) => (
<div
key={prompt.id}
className="p-3 border border-light-secondary dark:border-dark-secondary rounded-md bg-light-100 dark:bg-dark-100"
className="p-3 border border-surface-2 rounded-md bg-surface-2"
>
{editingPrompt && editingPrompt.id === prompt.id ? (
<div className="space-y-3">
@ -997,7 +1096,7 @@ export default function SettingsPage() {
})
}
placeholder="Prompt Name"
className="text-black dark:text-white bg-white dark:bg-dark-secondary"
className=""
/>
<Select
value={editingPrompt.type}
@ -1011,7 +1110,7 @@ export default function SettingsPage() {
{ value: 'system', label: 'System Prompt' },
{ value: 'persona', label: 'Persona Prompt' },
]}
className="text-black dark:text-white bg-white dark:bg-dark-secondary"
className=""
/>
<TextareaComponent
value={editingPrompt.content}
@ -1024,19 +1123,19 @@ export default function SettingsPage() {
})
}
placeholder="Prompt Content"
className="min-h-[100px] text-black dark:text-white bg-white dark:bg-dark-secondary"
className="min-h-[100px]"
/>
<div className="flex space-x-2 justify-end">
<button
onClick={() => setEditingPrompt(null)}
className="px-3 py-2 text-sm rounded-md bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 text-black/80 dark:text-white/80 flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md bg-surface hover:bg-surface-2 flex items-center gap-1.5"
>
<X size={16} />
Cancel
</button>
<button
onClick={handleAddOrUpdateSystemPrompt}
className="px-3 py-2 text-sm rounded-md bg-[#24A0ED] hover:bg-[#1f8cdb] text-white flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md bg-accent flex items-center gap-1.5"
>
<Save size={16} />
Save
@ -1046,11 +1145,9 @@ export default function SettingsPage() {
) : (
<div className="flex justify-between items-start">
<div className="flex-grow">
<h4 className="font-semibold text-black/90 dark:text-white/90">
{prompt.name}
</h4>
<h4 className="font-semibold">{prompt.name}</h4>
<p
className="text-sm text-black/70 dark:text-white/70 mt-1 whitespace-pre-wrap overflow-hidden text-ellipsis"
className="text-sm mt-1 whitespace-pre-wrap overflow-hidden text-ellipsis"
style={{
maxHeight: '3.6em',
display: '-webkit-box',
@ -1065,7 +1162,7 @@ export default function SettingsPage() {
<button
onClick={() => setEditingPrompt({ ...prompt })}
title="Edit"
className="p-1.5 rounded-md hover:bg-light-200 dark:hover:bg-dark-200 text-black/70 dark:text-white/70"
className="p-1.5 rounded-md hover:bg-surface-2"
>
<Edit3 size={18} />
</button>
@ -1074,7 +1171,7 @@ export default function SettingsPage() {
handleDeleteSystemPrompt(prompt.id)
}
title="Delete"
className="p-1.5 rounded-md hover:bg-light-200 dark:hover:bg-dark-200 text-red-500 hover:text-red-600 dark:text-red-400 dark:hover:text-red-500"
className="p-1.5 rounded-md hover:bg-surface-2 text-red-500 hover:text-red-600"
>
<Trash2 size={18} />
</button>
@ -1084,7 +1181,7 @@ export default function SettingsPage() {
</div>
))}
{isAddingNewPrompt && newPromptType === 'persona' && (
<div className="p-3 border border-dashed border-light-secondary dark:border-dark-secondary rounded-md space-y-3 bg-light-100 dark:bg-dark-100">
<div className="p-3 border border-dashed border-surface-2 rounded-md space-y-3 bg-surface-2">
<InputComponent
type="text"
value={newPromptName}
@ -1092,7 +1189,7 @@ export default function SettingsPage() {
setNewPromptName(e.target.value)
}
placeholder="Persona Prompt Name"
className="text-black dark:text-white bg-white dark:bg-dark-secondary"
className=""
/>
<TextareaComponent
value={newPromptContent}
@ -1100,7 +1197,7 @@ export default function SettingsPage() {
setNewPromptContent(e.target.value)
}
placeholder="Persona prompt content (e.g., You are a helpful assistant that speaks like a pirate and uses nautical metaphors.)"
className="min-h-[100px] text-black dark:text-white bg-white dark:bg-dark-secondary"
className="min-h-[100px]"
/>
<div className="flex space-x-2 justify-end">
<button
@ -1110,14 +1207,14 @@ export default function SettingsPage() {
setNewPromptContent('');
setNewPromptType('system');
}}
className="px-3 py-2 text-sm rounded-md bg-light-secondary hover:bg-light-200 dark:bg-dark-secondary dark:hover:bg-dark-200 text-black/80 dark:text-white/80 flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md bg-surface hover:bg-surface-2 flex items-center gap-1.5"
>
<X size={16} />
Cancel
</button>
<button
onClick={handleAddOrUpdateSystemPrompt}
className="px-3 py-2 text-sm rounded-md bg-[#24A0ED] hover:bg-[#1f8cdb] text-white flex items-center gap-1.5"
className="px-3 py-2 text-sm rounded-md bg-accent flex items-center gap-1.5"
>
<Save size={16} />
Add Persona Prompt
@ -1131,7 +1228,7 @@ export default function SettingsPage() {
setIsAddingNewPrompt(true);
setNewPromptType('persona');
}}
className="self-start px-3 py-2 text-sm rounded-md border border-light-200 dark:border-dark-200 hover:bg-light-200 dark:hover:bg-dark-200 text-black/80 dark:text-white/80 flex items-center gap-1.5"
className="self-start px-3 py-2 text-sm rounded-md border border-surface-2 hover:bg-surface-2 flex items-center gap-1.5"
>
<PlusCircle size={18} /> Add Persona Prompt
</button>
@ -1145,9 +1242,7 @@ export default function SettingsPage() {
>
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Optimization Mode
</p>
<p className="text-sm">Optimization Mode</p>
<div className="flex justify-start items-center space-x-2">
<Optimization
optimizationMode={searchOptimizationMode}
@ -1163,7 +1258,7 @@ export default function SettingsPage() {
setSearchOptimizationMode('');
localStorage.removeItem('searchOptimizationMode');
}}
className="p-1.5 rounded-md hover:bg-light-200 dark:hover:bg-dark-200 text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80 transition-colors"
className="p-1.5 rounded-md hover:bg-surface-2 transition-colors"
title="Reset optimization mode"
>
<RotateCcw size={16} />
@ -1173,9 +1268,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Chat Model
</p>
<p className="text-sm">Chat Model</p>
<div className="flex justify-start items-center space-x-2">
<ModelSelector
selectedModel={{
@ -1201,7 +1294,7 @@ export default function SettingsPage() {
localStorage.removeItem('searchChatModelProvider');
localStorage.removeItem('searchChatModel');
}}
className="p-1.5 rounded-md hover:bg-light-200 dark:hover:bg-dark-200 text-black/50 dark:text-white/50 hover:text-black/80 dark:hover:text-white/80 transition-colors"
className="p-1.5 rounded-md hover:bg-surface-2 transition-colors"
title="Reset chat model"
>
<RotateCcw size={16} />
@ -1216,9 +1309,7 @@ export default function SettingsPage() {
{config.chatModelProviders && (
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Chat Model Provider
</p>
<p className="text-sm">Chat Model Provider</p>
<Select
value={selectedChatModelProvider ?? undefined}
onChange={(e) => {
@ -1247,9 +1338,7 @@ export default function SettingsPage() {
{selectedChatModelProvider &&
selectedChatModelProvider != 'custom_openai' && (
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Chat Model
</p>
<p className="text-sm">Chat Model</p>
<Select
value={selectedChatModel ?? undefined}
onChange={(e) => {
@ -1287,9 +1376,7 @@ export default function SettingsPage() {
/>
{selectedChatModelProvider === 'ollama' && (
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Chat Context Window Size
</p>
<p className="text-sm">Chat Context Window Size</p>
<Select
value={
isCustomContextWindow
@ -1350,7 +1437,7 @@ export default function SettingsPage() {
/>
</div>
)}
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
<p className="text-xs mt-0.5">
{isCustomContextWindow
? 'Adjust the context window size for Ollama models (minimum 512 tokens)'
: 'Adjust the context window size for Ollama models'}
@ -1366,9 +1453,7 @@ export default function SettingsPage() {
selectedChatModelProvider === 'custom_openai' && (
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Model Name
</p>
<p className="text-sm">Model Name</p>
<InputComponent
type="text"
placeholder="Model name"
@ -1386,9 +1471,7 @@ export default function SettingsPage() {
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Custom OpenAI API Key
</p>
<p className="text-sm">Custom OpenAI API Key</p>
<InputComponent
type="password"
placeholder="Custom OpenAI API Key"
@ -1406,9 +1489,7 @@ export default function SettingsPage() {
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Custom OpenAI Base URL
</p>
<p className="text-sm">Custom OpenAI Base URL</p>
<InputComponent
type="text"
placeholder="Custom OpenAI Base URL"
@ -1429,11 +1510,9 @@ export default function SettingsPage() {
)}
{config.embeddingModelProviders && (
<div className="flex flex-col space-y-4 mt-4 pt-4 border-t border-light-200 dark:border-dark-200">
<div className="flex flex-col space-y-4 mt-4 pt-4 border-t border-surface-2">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Embedding Model Provider
</p>
<p className="text-sm">Embedding Model Provider</p>
<Select
value={selectedEmbeddingModelProvider ?? undefined}
onChange={(e) => {
@ -1461,9 +1540,7 @@ export default function SettingsPage() {
{selectedEmbeddingModelProvider && (
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Embedding Model
</p>
<p className="text-sm">Embedding Model</p>
<Select
value={selectedEmbeddingModel ?? undefined}
onChange={(e) => {
@ -1552,35 +1629,29 @@ export default function SettingsPage() {
return (
<div
key={providerId}
className="border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden"
className="border border-surface-2 rounded-lg overflow-hidden"
>
<button
onClick={() => toggleProviderExpansion(providerId)}
className="w-full p-3 bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition-colors flex items-center justify-between"
className="w-full p-3 bg-surface hover:bg-surface-2 transition-colors flex items-center justify-between"
>
<div className="flex items-center space-x-3">
{isExpanded ? (
<ChevronDown
size={16}
className="text-black/70 dark:text-white/70"
/>
<ChevronDown size={16} />
) : (
<ChevronRight
size={16}
className="text-black/70 dark:text-white/70"
/>
<ChevronRight size={16} />
)}
<h4 className="text-sm font-medium text-black/80 dark:text-white/80">
<h4 className="text-sm font-medium">
{(PROVIDER_METADATA as any)[provider]
?.displayName ||
provider.charAt(0).toUpperCase() +
provider.slice(1)}
</h4>
</div>
<div className="flex items-center space-x-2 text-xs text-black/60 dark:text-white/60">
<div className="flex items-center space-x-2 text-xs">
<span>{totalCount - hiddenCount} visible</span>
{hiddenCount > 0 && (
<span className="px-2 py-1 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded">
<span className="px-2 py-1 bg-red-100 text-red-700 rounded">
{hiddenCount} hidden
</span>
)}
@ -1588,14 +1659,44 @@ export default function SettingsPage() {
</button>
{isExpanded && (
<div className="p-3 bg-light-100 dark:bg-dark-100 border-t border-light-200 dark:border-dark-200">
<div className="p-3 bg-surface-2 border-t border-surface-2">
<div className="flex justify-end mb-3 space-x-2">
<button
onClick={(e) => {
e.stopPropagation();
handleProviderVisibilityToggle(
models,
true,
);
}}
className="px-3 py-1.5 text-xs rounded-md bg-green-100 hover:bg-green-200 text-green-700 flex items-center gap-1.5 transition-colors"
title="Show all models in this provider"
>
<Eye size={14} />
Show All
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleProviderVisibilityToggle(
models,
false,
);
}}
className="px-3 py-1.5 text-xs rounded-md bg-red-100 hover:bg-red-200 text-red-700 flex items-center gap-1.5 transition-colors"
title="Hide all models in this provider"
>
<EyeOff size={14} />
Hide All
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{modelEntries.map(([modelKey, model]) => (
<div
key={`${provider}-${modelKey}`}
className="flex items-center justify-between p-2 bg-white dark:bg-dark-secondary rounded-md"
className="flex items-center justify-between p-2 bg-surface rounded-md"
>
<span className="text-sm text-black/90 dark:text-white/90">
<span className="text-sm">
{model.displayName || modelKey}
</span>
<Switch
@ -1608,8 +1709,8 @@ export default function SettingsPage() {
}}
className={cn(
!hiddenModels.includes(modelKey)
? 'bg-[#24A0ED]'
: 'bg-light-200 dark:bg-dark-200',
? 'bg-accent'
: 'bg-surface-2',
'relative inline-flex h-5 w-9 items-center rounded-full transition-colors focus:outline-none',
)}
>
@ -1631,9 +1732,7 @@ export default function SettingsPage() {
);
})
) : (
<p className="text-sm text-black/60 dark:text-white/60 italic">
No models available
</p>
<p className="text-sm italic">No models available</p>
);
})()}
</div>
@ -1645,9 +1744,7 @@ export default function SettingsPage() {
>
<div className="flex flex-col space-y-4">
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
OpenAI API Key
</p>
<p className="text-sm">OpenAI API Key</p>
<InputComponent
type="password"
placeholder="OpenAI API Key"
@ -1664,9 +1761,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Ollama API URL
</p>
<p className="text-sm">Ollama API URL</p>
<InputComponent
type="text"
placeholder="Ollama API URL"
@ -1683,9 +1778,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
GROQ API Key
</p>
<p className="text-sm">GROQ API Key</p>
<InputComponent
type="password"
placeholder="GROQ API Key"
@ -1702,9 +1795,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
OpenRouter API Key
</p>
<p className="text-sm">OpenRouter API Key</p>
<InputComponent
type="password"
placeholder="OpenRouter API Key"
@ -1721,9 +1812,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Anthropic API Key
</p>
<p className="text-sm">Anthropic API Key</p>
<InputComponent
type="password"
placeholder="Anthropic API key"
@ -1740,9 +1829,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Gemini API Key
</p>
<p className="text-sm">Gemini API Key</p>
<InputComponent
type="password"
placeholder="Gemini API key"
@ -1759,9 +1846,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
Deepseek API Key
</p>
<p className="text-sm">Deepseek API Key</p>
<InputComponent
type="password"
placeholder="Deepseek API Key"
@ -1778,9 +1863,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
AI/ML API Key
</p>
<p className="text-sm">AI/ML API Key</p>
<InputComponent
type="text"
placeholder="AI/ML API Key"
@ -1797,28 +1880,7 @@ export default function SettingsPage() {
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
AI/ML API Key
</p>
<InputComponent
type="text"
placeholder="AI/ML API Key"
value={config.aimlApiKey}
isSaving={savingStates['aimlApiKey']}
onChange={(e) => {
setConfig((prev) => ({
...prev!,
aimlApiKey: e.target.value,
}));
}}
onSave={(value) => saveConfig('aimlApiKey', value)}
/>
</div>
<div className="flex flex-col space-y-1">
<p className="text-black/70 dark:text-white/70 text-sm">
LM Studio API URL
</p>
<p className="text-sm">LM Studio API URL</p>
<InputComponent
type="text"
placeholder="LM Studio API URL"

View file

@ -1,263 +0,0 @@
'use client';
import { useState } from 'react';
import { cn } from '@/lib/utils';
import {
ChevronDown,
ChevronUp,
Bot,
Search,
Zap,
Microscope,
Ban,
CircleCheck,
ListPlus,
} from 'lucide-react';
import { AgentActionEvent } from './ChatWindow';
interface AgentActionDisplayProps {
events: AgentActionEvent[];
messageId: string;
isLoading: boolean;
}
const AgentActionDisplay = ({
events,
messageId,
isLoading,
}: AgentActionDisplayProps) => {
const [isExpanded, setIsExpanded] = useState(false);
// Get the most recent event for collapsed view
const latestEvent = events[events.length - 1];
// Common function to format action names
const formatActionName = (action: string) => {
return action.replace(/_/g, ' ').toLocaleLowerCase();
};
// Function to get appropriate icon based on action type
const getActionIcon = (action: string, size: number = 20) => {
switch (action) {
case 'ANALYZING_PREVIEW_CONTENT':
return <Search size={size} className="text-[#9C27B0]" />;
case 'PROCESSING_PREVIEW_CONTENT':
return <Zap size={size} className="text-[#9C27B0]" />;
case 'PROCEEDING_WITH_FULL_ANALYSIS':
return <Microscope size={size} className="text-[#9C27B0]" />;
case 'SKIPPING_IRRELEVANT_SOURCE':
return <Ban size={size} className="text-red-600 dark:text-red-500" />;
case 'CONTEXT_UPDATED':
return (
<ListPlus
size={size}
className="text-green-600 dark:text-green-500"
/>
);
case 'INFORMATION_GATHERING_COMPLETE':
return (
<CircleCheck
size={size}
className="text-green-600 dark:text-green-500"
/>
);
default:
return <Bot size={size} className="text-[#9C27B0]" />;
}
};
if (!latestEvent) {
return null;
}
return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
className="w-full flex items-center justify-between px-4 py-3 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">
{getActionIcon(latestEvent.action)}
<span className="font-medium text-base text-black/70 dark:text-white/70 tracking-wide capitalize flex items-center">
{!isLoading ||
latestEvent.action === 'INFORMATION_GATHERING_COMPLETE'
? 'Agent Log'
: formatActionName(latestEvent.action)}
{/* {isLoading &&
latestEvent.action !== 'INFORMATION_GATHERING_COMPLETE' && (
<span className="ml-2 inline-block align-middle">
<span className="animate-spin inline-block w-4 h-4 border-2 border-t-transparent border-[#9C27B0] rounded-full align-middle"></span>
</span>
)} */}
</span>
</div>
{isExpanded ? (
<ChevronUp size={18} className="text-black/70 dark:text-white/70" />
) : (
<ChevronDown size={18} className="text-black/70 dark:text-white/70" />
)}
</button>
{isExpanded && (
<div className="px-4 py-3 text-black/80 dark:text-white/80 text-base border-t border-light-200 dark:border-dark-200 bg-light-100/50 dark:bg-dark-100/50">
<div className="space-y-3">
{events.map((event, index) => (
<div
key={`${messageId}-${index}-${event.action}`}
className="flex flex-col space-y-1 p-3 bg-white/50 dark:bg-black/20 rounded-lg border border-light-200/50 dark:border-dark-200/50"
>
<div className="flex items-center space-x-2">
{getActionIcon(event.action, 16)}
<span className="font-medium text-sm text-black/70 dark:text-white/70 capitalize tracking-wide">
{formatActionName(event.action)}
</span>
</div>
{event.message && event.message.length > 0 && (
<p className="text-base">{event.message}</p>
)}
{/* Display relevant details based on event type */}
{event.details && Object.keys(event.details).length > 0 && (
<div className="mt-2 text-sm text-black/60 dark:text-white/60">
{event.details.sourceUrl && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Source:
</span>
<span className="truncate">
<a href={event.details.sourceUrl} target="_blank">
{event.details.sourceUrl}
</a>
</span>
</div>
)}
{event.details.skipReason && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Reason:
</span>
<span>{event.details.skipReason}</span>
</div>
)}
{event.details.searchQuery &&
event.details.searchQuery !== event.details.query && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Search Query:
</span>
<span className="italic">
&quot;{event.details.searchQuery}&quot;
</span>
</div>
)}
{event.details.sourcesFound !== undefined && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Sources Found:
</span>
<span>{event.details.sourcesFound}</span>
</div>
)}
{/* {(event.details.documentCount !== undefined && event.details.documentCount > 0) && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">Documents:</span>
<span>{event.details.documentCount}</span>
</div>
)} */}
{event.details.contentLength !== undefined && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Content Length:
</span>
<span>{event.details.contentLength} characters</span>
</div>
)}
{event.details.searchInstructions !== undefined && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Search Instructions:
</span>
<span>{event.details.searchInstructions}</span>
</div>
)}
{/* {event.details.previewCount !== undefined && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">Preview Sources:</span>
<span>{event.details.previewCount}</span>
</div>
)} */}
{event.details.processingType && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Processing Type:
</span>
<span className="capitalize">
{event.details.processingType.replace('-', ' ')}
</span>
</div>
)}
{event.details.insufficiencyReason && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Reason:
</span>
<span>{event.details.insufficiencyReason}</span>
</div>
)}
{event.details.reason && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Reason:
</span>
<span>{event.details.reason}</span>
</div>
)}
{/* {event.details.taskCount !== undefined && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">Tasks:</span>
<span>{event.details.taskCount}</span>
</div>
)} */}
{event.details.currentTask && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Current Task:
</span>
<span className="italic">
&quot;{event.details.currentTask}&quot;
</span>
</div>
)}
{event.details.nextTask && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Next:
</span>
<span className="italic">
&quot;{event.details.nextTask}&quot;
</span>
</div>
)}
{event.details.currentSearchFocus && (
<div className="flex space-x-1">
<span className="font-bold whitespace-nowrap">
Search Focus:
</span>
<span className="italic">
&quot;{event.details.currentSearchFocus}&quot;
</span>
</div>
)}
</div>
)}
</div>
))}
</div>
</div>
)}
</div>
);
};
export default AgentActionDisplay;

View file

@ -5,7 +5,6 @@ import { File, Message } from './ChatWindow';
import MessageBox from './MessageBox';
import MessageBoxLoading from './MessageBoxLoading';
import MessageInput from './MessageInput';
import AgentActionDisplay from './AgentActionDisplay';
const Chat = ({
loading,
@ -25,6 +24,7 @@ const Chat = ({
analysisProgress,
systemPromptIds,
setSystemPromptIds,
onThinkBoxToggle,
}: {
messages: Message[];
sendMessage: (
@ -54,6 +54,11 @@ const Chat = ({
} | null;
systemPromptIds: string[];
setSystemPromptIds: (ids: string[]) => void;
onThinkBoxToggle: (
messageId: string,
thinkBoxId: string,
expanded: boolean,
) => void;
}) => {
const [isAtBottom, setIsAtBottom] = useState(true);
const [manuallyScrolledUp, setManuallyScrolledUp] = useState(false);
@ -224,32 +229,10 @@ const Chat = ({
rewrite={rewrite}
sendMessage={sendMessage}
handleEditMessage={handleEditMessage}
onThinkBoxToggle={onThinkBoxToggle}
/>
{/* Show agent actions after user messages - either completed or in progress */}
{msg.role === 'user' && (
<>
{/* Show agent actions if they exist */}
{msg.agentActions && msg.agentActions.length > 0 && (
<AgentActionDisplay
messageId={msg.messageId}
events={msg.agentActions}
isLoading={loading}
/>
)}
{/* Show empty agent action display if this is the last user message and we're loading */}
{loading &&
isLast &&
(!msg.agentActions || msg.agentActions.length === 0) && (
<AgentActionDisplay
messageId={msg.messageId}
events={[]}
isLoading={loading}
/>
)}
</>
)}
{!isLast && msg.role === 'assistant' && (
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
<div className="h-px w-full bg-surface-2" />
)}
</Fragment>
);
@ -265,7 +248,7 @@ const Chat = ({
setIsAtBottom(true);
messageEnd.current?.scrollIntoView({ behavior: 'smooth' });
}}
className="bg-[#24A0ED] text-white hover:bg-opacity-85 transition duration-100 rounded-full px-4 py-2 shadow-lg flex items-center justify-center"
className="bg-accent text-fg hover:bg-opacity-85 transition duration-100 rounded-full px-4 py-2 shadow-lg flex items-center justify-center"
aria-label="Scroll to bottom"
>
<svg

View file

@ -16,6 +16,11 @@ import NextError from 'next/error';
export type ModelStats = {
modelName: string;
responseTime?: number;
usage?: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
};
};
export type AgentActionEvent = {
@ -36,13 +41,13 @@ export type Message = {
modelStats?: ModelStats;
searchQuery?: string;
searchUrl?: string;
agentActions?: AgentActionEvent[];
progress?: {
message: string;
current: number;
total: number;
subMessage?: string;
};
expandedThinkBoxes?: Set<string>;
};
export interface File {
@ -415,6 +420,9 @@ const ChatWindow = ({ id }: { id?: string }) => {
let sources: Document[] | undefined = undefined;
let recievedMessage = '';
let messageBuffer = '';
let tokenCount = 0;
const bufferThreshold = 5;
let added = false;
let messageChatHistory = chatHistory;
@ -467,33 +475,6 @@ const ChatWindow = ({ id }: { id?: string }) => {
return;
}
if (data.type === 'agent_action') {
const agentActionEvent: AgentActionEvent = {
action: data.data.action,
message: data.data.message,
details: data.data.details || {},
timestamp: new Date(),
};
// Update the user message with agent actions
setMessages((prev) =>
prev.map((message) => {
if (
message.messageId === data.messageId &&
message.role === 'user'
) {
const updatedActions = [
...(message.agentActions || []),
agentActionEvent,
];
return { ...message, agentActions: updatedActions };
}
return message;
}),
);
return;
}
if (data.type === 'sources') {
sources = data.data;
if (!added) {
@ -512,57 +493,107 @@ const ChatWindow = ({ id }: { id?: string }) => {
]);
added = true;
setScrollTrigger((prev) => prev + 1);
} else {
// set the sources
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, sources: sources };
}
return message;
}),
);
}
}
if (data.type === 'message') {
if (data.type === 'tool_call') {
// Add the tool content to the current assistant message (already formatted with newlines)
const toolContent = data.data.content;
if (!added) {
// Create initial message with tool content
setMessages((prevMessages) => [
...prevMessages,
{
content: data.data,
messageId: data.messageId,
content: toolContent,
messageId: data.messageId, // Use the AI message ID from the backend
chatId: chatId!,
role: 'assistant',
sources: sources,
createdAt: new Date(),
modelStats: {
modelName: data.modelName,
},
},
]);
added = true;
} else {
// Append tool content to existing message
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, content: message.content + data.data };
return {
...message,
content: message.content + toolContent,
};
}
return message;
}),
);
}
recievedMessage += data.data;
recievedMessage += toolContent;
setScrollTrigger((prev) => prev + 1);
return;
}
if (data.type === 'response') {
// Add to buffer instead of immediately updating UI
messageBuffer += data.data;
recievedMessage += data.data;
tokenCount++;
// Only update UI every bufferThreshold tokens
if (tokenCount >= bufferThreshold) {
if (!added) {
setMessages((prevMessages) => [
...prevMessages,
{
content: messageBuffer,
messageId: data.messageId, // Use the AI message ID from the backend
chatId: chatId!,
role: 'assistant',
sources: sources,
createdAt: new Date(),
},
]);
added = true;
} else {
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return { ...message, content: recievedMessage };
}
return message;
}),
);
}
// Reset buffer and counter
messageBuffer = '';
tokenCount = 0;
setScrollTrigger((prev) => prev + 1);
}
}
if (data.type === 'messageEnd') {
// Clear analysis progress
setAnalysisProgress(null);
setChatHistory((prevHistory) => [
...prevHistory,
['human', message],
['assistant', recievedMessage],
]);
// Always update the message, adding modelStats if available
// Ensure final message content is displayed (flush any remaining buffer)
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === data.messageId) {
return {
...message,
content: recievedMessage, // Use the complete received message
// Include model stats if available, otherwise null
modelStats: data.modelStats || null,
// Make sure the searchQuery is preserved (if available in the message data)
@ -574,6 +605,12 @@ const ChatWindow = ({ id }: { id?: string }) => {
}),
);
setChatHistory((prevHistory) => [
...prevHistory,
['human', message],
['assistant', recievedMessage],
]);
setLoading(false);
setScrollTrigger((prev) => prev + 1);
@ -703,6 +740,27 @@ const ChatWindow = ({ id }: { id?: string }) => {
}
};
const handleThinkBoxToggle = (
messageId: string,
thinkBoxId: string,
expanded: boolean,
) => {
setMessages((prev) =>
prev.map((message) => {
if (message.messageId === messageId) {
const expandedThinkBoxes = new Set(message.expandedThinkBoxes || []);
if (expanded) {
expandedThinkBoxes.add(thinkBoxId);
} else {
expandedThinkBoxes.delete(thinkBoxId);
}
return { ...message, expandedThinkBoxes };
}
return message;
}),
);
};
useEffect(() => {
if (isReady && initialMessage && isConfigReady) {
// Check if we have an initial query and apply saved search settings
@ -754,7 +812,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
</Link>
</div>
<div className="flex flex-col items-center justify-center min-h-screen">
<p className="dark:text-white/70 text-black/70 text-sm">
<p className="text-sm">
Failed to connect to the server. Please try again later.
</p>
</div>
@ -788,6 +846,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
analysisProgress={analysisProgress}
systemPromptIds={systemPromptIds}
setSystemPromptIds={setSystemPromptIds}
onThinkBoxToggle={handleThinkBoxToggle}
/>
</>
) : (
@ -811,7 +870,7 @@ const ChatWindow = ({ id }: { id?: string }) => {
<div className="flex flex-row items-center justify-center min-h-screen">
<svg
aria-hidden="true"
className="w-8 h-8 text-light-200 fill-light-secondary dark:text-[#202020] animate-spin dark:fill-[#ffffff3b]"
className="w-8 h-8 text-fg/20 fill-fg/30 animate-spin"
viewBox="0 0 100 101"
fill="none"
xmlns="http://www.w3.org/2000/svg"

View file

@ -0,0 +1,86 @@
import { Document } from '@langchain/core/documents';
import { useState, useRef } from 'react';
import { createPortal } from 'react-dom';
import MessageSource from './MessageSource';
interface CitationLinkProps {
number: string;
source?: Document;
url?: string;
}
const CitationLink = ({ number, source, url }: CitationLinkProps) => {
const [showTooltip, setShowTooltip] = useState(false);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const spanRef = useRef<HTMLSpanElement>(null);
const linkContent = (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="bg-surface px-1 rounded ml-1 no-underline text-xs text-fg/70 relative hover:bg-surface-2 transition-colors duration-200 border border-surface-2"
>
{number}
</a>
);
const handleMouseEnter = () => {
if (spanRef.current) {
const rect = spanRef.current.getBoundingClientRect();
setTooltipPosition({
x: rect.left + rect.width / 2,
y: rect.top,
});
setShowTooltip(true);
}
};
const handleMouseLeave = () => {
setShowTooltip(false);
};
// If we have source data, wrap with tooltip
if (source) {
return (
<>
<span
ref={spanRef}
className="relative inline-block"
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{linkContent}
</span>
{showTooltip &&
typeof window !== 'undefined' &&
createPortal(
<div
className="fixed z-50 animate-in fade-in-0 duration-150"
style={{
left: tooltipPosition.x,
top: tooltipPosition.y - 8,
transform: 'translate(-50%, -100%)',
}}
>
<div className="bg-surface border rounded-lg border-surface-2 shadow-lg w-96">
<MessageSource
source={source}
className="shadow-none border-none bg-transparent hover:bg-transparent cursor-pointer"
/>
</div>
{/* Tooltip arrow */}
<div className="absolute top-full left-1/2 transform -translate-x-1/2 w-0 h-0 border-l-4 border-r-4 border-t-4 border-transparent border-t-surface-2"></div>
</div>,
document.body,
)}
</>
);
}
// Otherwise, just return the plain link
return linkContent;
};
export default CitationLink;

View file

@ -75,7 +75,7 @@ const DeleteChat = ({
}
}}
>
<DialogBackdrop className="fixed inset-0 bg-black/30" />
<DialogBackdrop className="fixed inset-0 bg-fg/30" />
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4 text-center">
<TransitionChild
@ -87,11 +87,11 @@ const DeleteChat = ({
leaveFrom="opacity-100 scale-200"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-light-secondary dark:bg-dark-secondary border border-light-200 dark:border-dark-200 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6 dark:text-white">
<DialogPanel className="w-full max-w-md transform rounded-2xl bg-surface border border-surface-2 p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle className="text-lg font-medium leading-6">
Delete Confirmation
</DialogTitle>
<Description className="text-sm dark:text-white/70 text-black/70">
<Description className="text-sm">
Are you sure you want to delete this chat?
</Description>
<div className="flex flex-row items-end justify-end space-x-4 mt-6">
@ -101,7 +101,7 @@ const DeleteChat = ({
setConfirmationDialogOpen(false);
}
}}
className="text-black/50 dark:text-white/50 text-sm hover:text-black/70 hover:dark:text-white/70 transition duration-200"
className="text-sm transition duration-200"
>
Cancel
</button>

View file

@ -1,5 +1,5 @@
import { Settings } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { File } from './ChatWindow';
import Link from 'next/link';
import MessageInput from './MessageInput';
@ -31,6 +31,13 @@ const EmptyChat = ({
files: File[];
setFiles: (files: File[]) => void;
}) => {
const [showWeatherWidget, setShowWeatherWidget] = useState(true);
const [showNewsWidget, setShowNewsWidget] = useState(true);
useEffect(() => {
setShowWeatherWidget(localStorage.getItem('showWeatherWidget') !== 'false');
setShowNewsWidget(localStorage.getItem('showNewsWidget') !== 'false');
}, []);
return (
<div className="relative">
<div className="absolute w-full flex flex-row items-center justify-end mr-5 mt-5">
@ -40,9 +47,7 @@ const EmptyChat = ({
</div>
<div className="flex flex-col items-center justify-center min-h-screen max-w-screen-sm mx-auto p-2 space-y-4">
<div className="flex flex-col items-center justify-center w-full space-y-8">
<h2 className="text-black/70 dark:text-white/70 text-3xl font-medium -mt-8">
Research begins here.
</h2>
{/* <h2 className="text-3xl font-medium -mt-8">Research begins here.</h2> */}
<MessageInput
firstMessage={true}
loading={false}
@ -60,12 +65,16 @@ const EmptyChat = ({
/>
</div>
<div className="flex flex-col w-full gap-4 mt-2 sm:flex-row sm:justify-center">
<div className="flex-1 w-full">
<WeatherWidget />
</div>
<div className="flex-1 w-full">
<NewsArticleWidget />
</div>
{showWeatherWidget && (
<div className="flex-1 w-full">
<WeatherWidget />
</div>
)}
{showNewsWidget && (
<div className="flex-1 w-full">
<NewsArticleWidget />
</div>
)}
</div>
</div>
</div>

View file

@ -1,7 +1,16 @@
'use client';
import { useSelectedLayoutSegments } from 'next/navigation';
const Layout = ({ children }: { children: React.ReactNode }) => {
const segments = useSelectedLayoutSegments();
const isDashboard = segments.includes('dashboard');
return (
<main className="lg:pl-20 bg-light-primary dark:bg-dark-primary min-h-screen">
<div className="max-w-screen-lg lg:mx-auto mx-4">{children}</div>
<main className="lg:pl-20 bg-bg min-h-screen">
<div className={isDashboard ? 'mx-4' : 'max-w-screen-lg lg:mx-auto mx-4'}>
{children}
</div>
</main>
);
};

View file

@ -0,0 +1,400 @@
/* eslint-disable @next/next/no-img-element */
'use client';
import { cn } from '@/lib/utils';
import {
CheckCheck,
Copy as CopyIcon,
Search,
FileText,
Globe,
Settings,
Image as ImageIcon,
BotIcon,
} from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useEffect, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import {
oneDark,
oneLight,
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import ThinkBox from './ThinkBox';
import { Document } from '@langchain/core/documents';
import CitationLink from './CitationLink';
import { decodeHtmlEntities } from '@/lib/utils/html';
// 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();
};
// Add stable IDs to think tags if they don't already have them
const addThinkBoxIds = (content: string): string => {
let thinkCounter = 0;
return content.replace(/<think(?![^>]*\sid=)/g, () => {
return `<think id="think-${thinkCounter++}"`;
});
};
interface MarkdownRendererProps {
content: string;
className?: string;
showThinking?: boolean;
messageId?: string;
expandedThinkBoxes?: Set<string>;
onThinkBoxToggle?: (
messageId: string,
thinkBoxId: string,
expanded: boolean,
) => void;
sources?: Document[];
}
// Custom ToolCall component for markdown
const ToolCall = ({
type,
query,
urls,
count,
children,
}: {
type?: string;
query?: string;
urls?: string;
count?: string;
children?: React.ReactNode;
}) => {
const getIcon = (toolType: string) => {
switch (toolType) {
case 'search':
case 'web_search':
return <Search size={16} className="text-accent" />;
case 'file':
case 'file_search':
return <FileText size={16} className="text-green-600" />;
case 'url':
case 'url_summarization':
return <Globe size={16} className="text-purple-600" />;
case 'image':
case 'image_search':
return <ImageIcon size={16} className="text-blue-600" />;
case 'firefoxAI':
return <BotIcon size={16} className="text-indigo-600" />;
default:
return <Settings size={16} className="text-fg/70" />;
}
};
const formatToolMessage = () => {
if (type === 'search' || type === 'web_search') {
return (
<>
<span className="mr-2">{getIcon(type)}</span>
<span>Web search:</span>
<span className="ml-2 px-2 py-0.5 bg-fg/5 rounded font-mono text-sm">
{decodeHtmlEntities(query || (children as string))}
</span>
</>
);
}
if (type === 'file' || type === 'file_search') {
return (
<>
<span className="mr-2">{getIcon(type)}</span>
<span>File search:</span>
<span className="ml-2 px-2 py-0.5 bg-fg/5 rounded font-mono text-sm">
{decodeHtmlEntities(query || (children as string))}
</span>
</>
);
}
if (type === 'url' || type === 'url_summarization') {
const urlCount = count ? parseInt(count) : 1;
return (
<>
<span className="mr-2">{getIcon(type)}</span>
<span>
Analyzing {urlCount} web page{urlCount === 1 ? '' : 's'} for
additional details
</span>
</>
);
}
if (type === 'image' || type === 'image_search') {
return (
<>
<span className="mr-2">{getIcon(type)}</span>
<span>Image search:</span>
<span className="ml-2 px-2 py-0.5 bg-fg/5 rounded font-mono text-sm">
{decodeHtmlEntities(query || (children as string))}
</span>
</>
);
}
if (type === 'firefoxAI') {
return (
<>
<span className="mr-2">{getIcon(type)}</span>
<span>Firefox AI detected, tools disabled</span>
</>
);
}
// Fallback for unknown tool types
return (
<>
<span className="mr-2">{getIcon(type || 'default')}</span>
<span>Using tool:</span>
<span className="ml-2 px-2 py-0.5 bg-fg/5 rounded font-mono text-sm border border-surface-2">
{type || 'unknown'}
</span>
</>
);
};
return (
<div className="my-3 px-4 py-3 bg-surface border border-surface-2 rounded-lg">
<div className="flex items-center text-sm font-medium">
{formatToolMessage()}
</div>
</div>
);
};
const ThinkTagProcessor = ({
children,
id,
isExpanded,
onToggle,
}: {
children: React.ReactNode;
id?: string;
isExpanded?: boolean;
onToggle?: (thinkBoxId: string, expanded: boolean) => void;
}) => {
return (
<ThinkBox
content={children}
expanded={isExpanded}
onToggle={() => {
if (id && onToggle) {
onToggle(id, !isExpanded);
}
}}
/>
);
};
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);
};
const root = document.documentElement;
const isDark = root.classList.contains('dark');
const syntaxStyle = isDark ? oneDark : oneLight;
const backgroundStyle = isDark ? '#1c1c1c' : '#fafafa';
return (
<div className="rounded-md overflow-hidden my-4 relative group border border-surface-2">
<div className="flex justify-between items-center px-4 py-2 bg-surface-2 border-b border-surface-2 text-xs text-fg/70 font-mono">
<span>{language}</span>
<button
onClick={handleCopyCode}
className="p-1 rounded-md hover:bg-surface transition duration-200"
aria-label="Copy code to clipboard"
>
{isCopied ? (
<CheckCheck size={14} className="text-green-500" />
) : (
<CopyIcon size={14} className="text-fg" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={syntaxStyle}
customStyle={{
margin: 0,
padding: '1rem',
borderRadius: 0,
backgroundColor: backgroundStyle,
}}
wrapLines
wrapLongLines
showLineNumbers={language !== '' && content.split('\n').length > 1}
useInlineStyles
PreTag="div"
>
{content}
</SyntaxHighlighter>
</div>
);
};
const MarkdownRenderer = ({
content,
className,
showThinking = true,
messageId,
expandedThinkBoxes,
onThinkBoxToggle,
sources,
}: MarkdownRendererProps) => {
// Preprocess content to add stable IDs to think tags
const processedContent = addThinkBoxIds(content);
// Check if a think box is expanded
const isThinkBoxExpanded = (thinkBoxId: string) => {
return expandedThinkBoxes?.has(thinkBoxId) || false;
};
// Handle think box toggle
const handleThinkBoxToggle = (thinkBoxId: string, expanded: boolean) => {
if (messageId && onThinkBoxToggle) {
onThinkBoxToggle(messageId, thinkBoxId, expanded);
}
};
// Determine what content to render based on showThinking parameter
const contentToRender = showThinking
? processedContent
: removeThinkTags(processedContent);
// Markdown formatting options
const markdownOverrides: MarkdownToJSX.Options = {
overrides: {
ToolCall: {
component: ToolCall,
},
think: {
component: ({ children, id, ...props }) => {
// Use the id from the HTML attribute
const thinkBoxId = id || 'think-unknown';
const isExpanded = isThinkBoxExpanded(thinkBoxId);
return (
<ThinkTagProcessor
id={thinkBoxId}
isExpanded={isExpanded}
onToggle={handleThinkBoxToggle}
>
{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-surface-2 font-mono text-sm">
{children}
</code>
);
},
},
strong: {
component: ({ children }) => (
<strong className="font-bold">{children}</strong>
),
},
pre: {
component: ({ children }) => children,
},
a: {
component: (props) => {
// Check if this is a citation link with data-citation attribute
const citationNumber = props['data-citation'];
if (sources && citationNumber) {
const number = parseInt(citationNumber);
const source = sources[number - 1];
if (source) {
return (
<CitationLink
number={number.toString()}
source={source}
url={props.href}
/>
);
}
}
// Default link behavior
return <a {...props} target="_blank" rel="noopener noreferrer" />;
},
},
// Prevent rendering of certain HTML elements for security
iframe: () => null, // Don't render iframes
script: () => null, // Don't render scripts
object: () => null, // Don't render objects
style: () => null, // Don't render styles
},
};
return (
<div className="relative">
<Markdown
className={cn(
'prose prose-theme dark:prose-invert 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-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',
'prose-strong:font-bold',
'break-words max-w-full',
className,
)}
options={markdownOverrides}
>
{contentToRender}
</Markdown>
</div>
);
};
export default MarkdownRenderer;

View file

@ -19,7 +19,7 @@ const Copy = ({
setCopied(true);
setTimeout(() => setCopied(false), 1000);
}}
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
className="p-2 rounded-xl transition duration-200"
>
{copied ? <Check size={18} /> : <ClipboardList size={18} />}
</button>

View file

@ -39,7 +39,7 @@ const ModelInfoButton: React.FC<ModelInfoButtonProps> = ({ modelStats }) => {
<div className="relative">
<button
ref={buttonRef}
className="p-1 ml-1 text-black/70 dark:text-white/70 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
className="p-1 ml-1 rounded-full hover:bg-surface-2 transition duration-200"
onClick={() => setShowPopover(!showPopover)}
aria-label="Show model information"
>
@ -48,29 +48,45 @@ const ModelInfoButton: React.FC<ModelInfoButtonProps> = ({ modelStats }) => {
{showPopover && (
<div
ref={popoverRef}
className="absolute z-10 left-6 top-0 w-64 rounded-md shadow-lg bg-white dark:bg-dark-secondary border border-light-200 dark:border-dark-200"
className="absolute z-10 left-6 top-0 w-72 rounded-md shadow-lg border border-surface-2 bg-surface"
>
<div className="py-2 px-3">
<h4 className="text-sm font-medium mb-2 text-black dark:text-white">
Model Information
</h4>
<h4 className="text-sm font-medium mb-2">Model Information</h4>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-black/70 dark:text-white/70">Model:</span>
<span className="text-black dark:text-white font-medium">
{modelName}
</span>
<div className="flex space-x-2">
<span className="">Model:</span>
<span className="font-medium">{modelName}</span>
</div>
{modelStats?.responseTime && (
<div className="flex justify-between">
<span className="text-black/70 dark:text-white/70">
Response time:
</span>
<span className="text-black dark:text-white font-medium">
<div className="flex space-x-2">
<span>Response time:</span>
<span className="font-medium">
{(modelStats.responseTime / 1000).toFixed(2)}s
</span>
</div>
)}
{modelStats?.usage && (
<>
<div className="flex space-x-2">
<span>Input tokens:</span>
<span className="font-medium">
{modelStats.usage.input_tokens.toLocaleString()}
</span>
</div>
<div className="flex space-x-2">
<span>Output tokens:</span>
<span className="font-medium">
{modelStats.usage.output_tokens.toLocaleString()}
</span>
</div>
<div className="flex space-x-2">
<span>Total tokens:</span>
<span className="font-medium">
{modelStats.usage.total_tokens.toLocaleString()}
</span>
</div>
</>
)}
</div>
</div>
</div>

View file

@ -10,7 +10,7 @@ const Rewrite = ({
return (
<button
onClick={() => rewrite(messageId)}
className="py-2 px-3 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white flex flex-row items-center space-x-1"
className="py-2 px-3 rounded-xl hover:bg-secondary transition duration-200 flex flex-row items-center space-x-1"
>
<ArrowLeftRight size={18} />
<p className="text-xs font-medium">Rewrite</p>

View file

@ -1,6 +1,6 @@
import { cn } from '@/lib/utils';
import { Check, Pencil, X } from 'lucide-react';
import { useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Message } from './ChatWindow';
import MessageTabs from './MessageTabs';
@ -13,6 +13,7 @@ const MessageBox = ({
rewrite,
sendMessage,
handleEditMessage,
onThinkBoxToggle,
}: {
message: Message;
messageIndex: number;
@ -29,10 +30,38 @@ const MessageBox = ({
},
) => void;
handleEditMessage: (messageId: string, content: string) => void;
onThinkBoxToggle: (
messageId: string,
thinkBoxId: string,
expanded: boolean,
) => void;
}) => {
// Local state for editing functionality
const [isEditing, setIsEditing] = useState(false);
const [editedContent, setEditedContent] = useState('');
// State for truncation toggle of long user prompts
const [isExpanded, setIsExpanded] = useState(false);
const [isOverflowing, setIsOverflowing] = useState(false);
const contentRef = useRef<HTMLHeadingElement | null>(null);
// Measure overflow compared to a 3-line clamped state
useEffect(() => {
const measureOverflow = () => {
const el = contentRef.current;
if (!el) return;
const hadClamp = el.classList.contains('line-clamp-3');
if (!hadClamp) el.classList.add('line-clamp-3');
const overflowing = el.scrollHeight > el.clientHeight + 1;
setIsOverflowing(overflowing);
if (!hadClamp) el.classList.remove('line-clamp-3');
};
measureOverflow();
window.addEventListener('resize', measureOverflow);
return () => {
window.removeEventListener('resize', measureOverflow);
};
}, [message.content]);
// Initialize editing
const startEditMessage = () => {
@ -64,7 +93,7 @@ const MessageBox = ({
{isEditing ? (
<div className="w-full">
<textarea
className="w-full p-3 text-lg bg-light-100 dark:bg-dark-100 rounded-lg border border-light-secondary dark:border-dark-secondary text-black dark:text-white focus:outline-none focus:border-[#24A0ED] transition duration-200 min-h-[120px] font-medium"
className="w-full p-3 text-lg bg-surface rounded-lg transition duration-200 min-h-[120px] font-medium text-fg placeholder:text-fg/40 border border-surface-2 focus:outline-none focus:ring-2 focus:ring-accent/40"
value={editedContent}
onChange={(e) => setEditedContent(e.target.value)}
placeholder="Edit your message..."
@ -73,7 +102,7 @@ const MessageBox = ({
<div className="flex flex-row space-x-2 mt-3 justify-end">
<button
onClick={cancelEditMessage}
className="p-2 rounded-full bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white"
className="p-2 rounded-full bg-surface hover:bg-surface-2 border border-surface-2 transition duration-200 text-fg/80"
aria-label="Cancel"
title="Cancel"
>
@ -81,27 +110,57 @@ const MessageBox = ({
</button>
<button
onClick={saveEditMessage}
className="p-2 rounded-full bg-[#24A0ED] hover:bg-[#1a8ad3] transition duration-200 text-white disabled:opacity-50 disabled:cursor-not-allowed"
className="p-2 rounded-full bg-accent hover:bg-accent-700 transition duration-200 text-white disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Save changes"
title="Save changes"
disabled={!editedContent.trim()}
>
<Check size={18} className="text-white" />
<Check size={18} />
</button>
</div>
</div>
) : (
<>
<div className="flex items-center">
<h2
className="text-black dark:text-white font-medium text-3xl"
onClick={startEditMessage}
>
{message.content}
</h2>
<div className="flex items-start">
<div className="flex-1 min-w-0">
<h2
className={cn(
'font-medium text-3xl',
!isExpanded && 'line-clamp-3',
)}
id={`user-msg-${message.messageId}`}
ref={contentRef}
onClick={startEditMessage}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
if (e.key === ' ') e.preventDefault();
startEditMessage();
}
}}
>
{message.content}
</h2>
{isOverflowing && (
<button
type="button"
className="mt-2 text-sm text-accent hover:underline"
onClick={(e) => {
e.stopPropagation();
setIsExpanded((v) => !v);
}}
aria-expanded={isExpanded}
aria-controls={`user-msg-${message.messageId}`}
title={isExpanded ? 'Show less' : 'Show more'}
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
<button
onClick={startEditMessage}
className="ml-3 p-2 rounded-xl bg-light-secondary dark:bg-dark-secondary text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white flex-shrink-0"
className="ml-3 p-2 rounded-xl bg-surface hover:bg-surface-2 border border-surface-2 flex-shrink-0"
aria-label="Edit message"
title="Edit message"
>
@ -123,6 +182,7 @@ const MessageBox = ({
loading={loading}
rewrite={rewrite}
sendMessage={sendMessage}
onThinkBoxToggle={onThinkBoxToggle}
/>
)}
</div>

View file

@ -11,22 +11,17 @@ const MessageBoxLoading = ({ progress }: MessageBoxLoadingProps) => {
return (
<div className="flex flex-col space-y-4 w-full lg:w-9/12">
{progress && progress.current !== progress.total ? (
<div className="bg-light-primary dark:bg-dark-primary rounded-lg p-4">
<div className="bg-surface rounded-lg p-4 border border-surface-2">
<div className="flex flex-col space-y-3">
<p className="text-base font-semibold text-black dark:text-white">
{progress.message}
</p>
<p className="text-base font-semibold">{progress.message}</p>
{progress.subMessage && (
<p
className="text-xs text-black/40 dark:text-white/40 mt-1"
title={progress.subMessage}
>
<p className="text-xs mt-1" title={progress.subMessage}>
{progress.subMessage}
</p>
)}
<div className="w-full bg-light-secondary dark:bg-dark-secondary rounded-full h-2 overflow-hidden">
<div className="w-full bg-surface-2 rounded-full h-2 overflow-hidden">
<div
className={`h-full bg-[#24A0ED] transition-all duration-300 ease-in-out ${
className={`h-full bg-accent transition-all duration-300 ease-in-out ${
progress.current === progress.total ? '' : 'animate-pulse'
}`}
style={{
@ -39,9 +34,9 @@ const MessageBoxLoading = ({ progress }: MessageBoxLoadingProps) => {
) : (
<div className="pl-3 flex items-center justify-start">
<div className="flex space-x-1">
<div className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-[high-bounce_1s_infinite] [animation-delay:-0.3s]"></div>
<div className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-[high-bounce_1s_infinite] [animation-delay:-0.15s]"></div>
<div className="w-1.5 h-1.5 bg-black/40 dark:bg-white/40 rounded-full animate-[high-bounce_1s_infinite]"></div>
<div className="w-1.5 h-1.5 bg-fg/40 rounded-full animate-[high-bounce_1s_infinite] [animation-delay:-0.3s]"></div>
<div className="w-1.5 h-1.5 bg-fg/40 rounded-full animate-[high-bounce_1s_infinite] [animation-delay:-0.15s]"></div>
<div className="w-1.5 h-1.5 bg-fg/40 rounded-full animate-[high-bounce_1s_infinite]"></div>
</div>
</div>
)}

View file

@ -52,7 +52,6 @@ const MessageInput = ({
} | null>(null);
useEffect(() => {
// Load saved model preferences from localStorage
const chatModelProvider = localStorage.getItem('chatModelProvider');
const chatModel = localStorage.getItem('chatModel');
@ -62,7 +61,9 @@ const MessageInput = ({
model: chatModel,
});
}
}, []);
useEffect(() => {
const storedPromptIds = localStorage.getItem('selectedSystemPromptIds');
if (storedPromptIds) {
try {
@ -141,15 +142,20 @@ const MessageInput = ({
}}
className="w-full"
>
<div className="flex flex-col bg-light-secondary dark:bg-dark-secondary px-3 pt-4 pb-2 rounded-lg w-full border border-light-200 dark:border-dark-200">
<div className="flex flex-row items-end space-x-2 mb-2">
<div className="flex flex-col bg-surface px-3 pt-4 pb-2 rounded-lg w-full border border-surface-2">
<div className="flex flex-row space-x-2 mb-2">
<TextareaAutosize
ref={inputRef}
value={message}
onChange={(e) => setMessage(e.target.value)}
minRows={1}
className="mb-2 bg-transparent placeholder:text-black/50 dark:placeholder:text-white/50 text-sm text-black dark:text-white resize-none focus:outline-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder={firstMessage ? 'Ask anything...' : 'Ask a follow-up'}
className="px-3 py-2 overflow-hidden flex rounded-lg bg-transparent text-sm resize-none w-full max-h-24 lg:max-h-36 xl:max-h-48"
placeholder={
firstMessage
? 'What would you like to learn today?'
: 'Ask a follow-up'
}
autoFocus={true}
/>
<Optimization
optimizationMode={optimizationMode}
@ -172,7 +178,11 @@ const MessageInput = ({
</div>
<div className="flex flex-row items-center space-x-2">
<ModelSelector
showModelName={false}
showModelName={
typeof window !== 'undefined'
? window.matchMedia('(min-width: 640px)').matches
: false
}
selectedModel={selectedModel}
setSelectedModel={(selectedModel) => {
setSelectedModel(selectedModel);
@ -195,7 +205,7 @@ const MessageInput = ({
aria-label="Cancel"
>
{loading && (
<div className="absolute inset-0 rounded-full border-2 border-white/30 border-t-white animate-spin" />
<div className="absolute inset-0 rounded-full border-2 border-fg/30 border-t-fg animate-spin" />
)}
<span className="relative flex items-center justify-center w-[17px] h-[17px]">
<Square size={17} className="text-white" />
@ -204,13 +214,13 @@ const MessageInput = ({
) : (
<button
disabled={message.trim().length === 0}
className="bg-[#24A0ED] text-white disabled:text-black/50 dark:disabled:text-white/50 disabled:bg-[#e0e0dc] dark:disabled:bg-[#ececec21] hover:bg-opacity-85 transition duration-100 rounded-full p-2"
className="bg-accent text-white disabled:text-white/50 disabled:bg-accent/20 hover:bg-accent-700 transition duration-100 rounded-full p-2"
type="submit"
>
{firstMessage ? (
<ArrowRight className="bg-background" size={17} />
<ArrowRight size={17} />
) : (
<ArrowUp className="bg-background" size={17} />
<ArrowUp size={17} />
)}
</button>
)}

View file

@ -84,20 +84,20 @@ const Attach = ({
'flex flex-row items-center justify-between space-x-1 p-2 rounded-xl transition duration-200',
files.length > 0 ? '-ml-2 lg:-ml-3' : '',
isDisabled
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
: 'text-black/50 dark:text-white/50 hover:bg-light-secondary dark:hover:bg-dark-secondary hover:text-black dark:hover:text-white',
? 'text-fg/20 cursor-not-allowed'
: 'text-fg/50 hover:bg-surface-2 hover:text-fg',
)}
>
{files.length > 1 && (
<>
<File
size={19}
className={isDisabled ? 'text-sky-900' : 'text-sky-400'}
className={isDisabled ? 'text-fg/20' : 'text-accent'}
/>
<p
className={cn(
'inline whitespace-nowrap text-xs font-medium',
isDisabled ? 'text-sky-900' : 'text-sky-400',
isDisabled ? 'text-fg/20' : 'text-accent',
)}
>
{files.length} files
@ -109,12 +109,12 @@ const Attach = ({
<>
<File
size={18}
className={isDisabled ? 'text-sky-900' : 'text-sky-400'}
className={isDisabled ? 'text-fg/20' : 'text-accent'}
/>
<p
className={cn(
'text-xs font-medium',
isDisabled ? 'text-sky-900' : 'text-sky-400',
isDisabled ? 'text-fg/20' : 'text-accent',
)}
>
{files[0].fileName.length > 10
@ -136,11 +136,9 @@ const Attach = ({
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-64 md:w-[350px] right-0">
<div className="bg-light-primary dark:bg-dark-primary border rounded-md border-light-200 dark:border-dark-200 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
<div className="bg-surface border rounded-md border-surface-2 w-full max-h-[200px] md:max-h-none overflow-y-auto flex flex-col">
<div className="flex flex-row items-center justify-between px-3 py-2">
<h4 className="text-black dark:text-white font-medium text-sm">
Attached files
</h4>
<h4 className="text-fg font-medium text-sm">Attached files</h4>
<div className="flex flex-row items-center space-x-4">
<button
type="button"
@ -149,8 +147,8 @@ const Attach = ({
className={cn(
'flex flex-row items-center space-x-1 transition duration-200',
isDisabled
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
: 'text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white',
? 'text-fg/20 cursor-not-allowed'
: 'text-fg/70 hover:text-fg',
)}
>
<input
@ -176,8 +174,8 @@ const Attach = ({
className={cn(
'flex flex-row items-center space-x-1 transition duration-200',
isDisabled
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
: 'text-black/70 dark:text-white/70 hover:text-black hover:dark:text-white',
? 'text-fg/20 cursor-not-allowed'
: 'text-fg/70 hover:text-fg',
)}
>
<Trash size={14} />
@ -185,17 +183,17 @@ const Attach = ({
</button>
</div>
</div>
<div className="h-[0.5px] mx-2 bg-white/10" />
<div className="h-[0.5px] mx-2 bg-surface-2" />
<div className="flex flex-col items-center">
{files.map((file, i) => (
<div
key={i}
className="flex flex-row items-center justify-start w-full space-x-3 p-3"
>
<div className="bg-dark-100 flex items-center justify-center w-10 h-10 rounded-md">
<File size={16} className="text-white/70" />
<div className="bg-surface-2 flex items-center justify-center w-10 h-10 rounded-md">
<File size={16} className="text-fg/70" />
</div>
<p className="text-black/70 dark:text-white/70 text-sm">
<p className="text-fg/70 text-sm">
{file.fileName.length > 25
? file.fileName.replace(/\.\w+$/, '').substring(0, 25) +
'...' +
@ -211,9 +209,9 @@ const Attach = ({
</Popover>
{isSpeedMode && (
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div className="bg-black dark:bg-white text-white dark:text-black text-xs px-2 py-1 rounded whitespace-nowrap">
<div className="bg-fg text-bg text-xs px-2 py-1 rounded whitespace-nowrap">
File attachments are disabled in Speed mode
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-white"></div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-fg"></div>
</div>
</div>
)}
@ -227,8 +225,8 @@ const Attach = ({
className={cn(
'flex flex-row items-center space-x-1 rounded-xl transition duration-200 p-2',
isDisabled
? 'text-black/20 dark:text-white/20 cursor-not-allowed'
: 'text-black/50 dark:text-white/50 hover:bg-light-secondary dark:hover:bg-dark-secondary hover:text-black dark:hover:text-white',
? 'text-fg/20 cursor-not-allowed'
: 'text-fg/50 hover:bg-surface-2 hover:text-fg',
)}
>
<input
@ -244,9 +242,9 @@ const Attach = ({
</button>
{isSpeedMode && (
<div className="absolute bottom-full mb-2 left-1/2 transform -translate-x-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none">
<div className="bg-black dark:bg-white text-white dark:text-black text-xs px-2 py-1 rounded whitespace-nowrap">
<div className="bg-fg text-bg text-xs px-2 py-1 rounded whitespace-nowrap">
File attachments are disabled in Speed mode
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-black dark:border-t-white"></div>
<div className="absolute top-full left-1/2 transform -translate-x-1/2 border-4 border-transparent border-t-fg"></div>
</div>
</div>
)}

View file

@ -1,43 +0,0 @@
import { cn } from '@/lib/utils';
import { Switch } from '@headlessui/react';
const CopilotToggle = ({
copilotEnabled,
setCopilotEnabled,
}: {
copilotEnabled: boolean;
setCopilotEnabled: (enabled: boolean) => void;
}) => {
return (
<div className="group flex flex-row items-center space-x-1 active:scale-95 duration-200 transition cursor-pointer">
<Switch
checked={copilotEnabled}
onChange={setCopilotEnabled}
className="bg-light-secondary dark:bg-dark-secondary border border-light-200/70 dark:border-dark-200 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full"
>
<span className="sr-only">Copilot</span>
<span
className={cn(
copilotEnabled
? 'translate-x-6 bg-[#24A0ED]'
: 'translate-x-1 bg-black/50 dark:bg-white/50',
'inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200',
)}
/>
</Switch>
<p
onClick={() => setCopilotEnabled(!copilotEnabled)}
className={cn(
'text-xs font-medium transition-colors duration-150 ease-in-out',
copilotEnabled
? 'text-[#24A0ED]'
: 'text-black/50 dark:text-white/50 group-hover:text-black dark:group-hover:text-white',
)}
>
Copilot
</p>
</div>
);
};
export default CopilotToggle;

View file

@ -7,7 +7,7 @@ const focusModes = [
key: 'webSearch',
title: 'All',
description: 'Searches across all of the internet',
icon: <Globe size={20} className="text-[#24A0ED]" />,
icon: <Globe size={20} className="text-accent" />,
},
{
key: 'chat',
@ -42,17 +42,17 @@ const Focus = ({
);
return (
<div className="text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white">
<div className="rounded-xl transition duration-200">
<div className="flex flex-row items-center space-x-1">
<div className="relative">
<div className="flex items-center border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden">
<div className="flex items-center border border-surface-2 rounded-lg overflow-hidden">
{/* Web Search Mode Icon */}
<button
className={cn(
'p-2 transition-all duration-200',
focusMode === 'webSearch'
? 'bg-[#24A0ED]/20 text-[#24A0ED] scale-105'
: 'text-black/30 dark:text-white/30 hover:text-black/50 dark:hover:text-white/50 hover:bg-light-secondary/50 dark:hover:bg-dark-secondary/50',
? 'text-accent scale-105'
: 'text-fg/70 hover:bg-surface-2',
)}
onMouseEnter={() => setShowWebSearchTooltip(true)}
onMouseLeave={() => setShowWebSearchTooltip(false)}
@ -65,15 +65,15 @@ const Focus = ({
</button>
{/* Divider */}
<div className="h-6 w-px bg-light-200 dark:bg-dark-200"></div>
<div className="h-6 w-px border-l opacity-10"></div>
{/* Chat Mode Icon */}
<button
className={cn(
'p-2 transition-all duration-200',
focusMode === 'chat'
? 'bg-[#10B981]/20 text-[#10B981] scale-105'
: 'text-black/30 dark:text-white/30 hover:text-black/50 dark:hover:text-white/50 hover:bg-light-secondary/50 dark:hover:bg-dark-secondary/50',
? 'text-accent scale-105'
: 'text-fg/70 hover:bg-surface-2',
)}
onMouseEnter={() => setShowChatTooltip(true)}
onMouseLeave={() => setShowChatTooltip(false)}
@ -86,15 +86,15 @@ const Focus = ({
</button>
{/* Divider */}
<div className="h-6 w-px bg-light-200 dark:bg-dark-200"></div>
<div className="h-6 w-px border-l opacity-10"></div>
{/* Local Research Mode Icon */}
<button
className={cn(
'p-2 transition-all duration-200',
focusMode === 'localResearch'
? 'bg-[#8B5CF6]/20 text-[#8B5CF6] scale-105'
: 'text-black/30 dark:text-white/30 hover:text-black/50 dark:hover:text-white/50 hover:bg-light-secondary/50 dark:hover:bg-dark-secondary/50',
? 'text-accent scale-105'
: 'text-fg/70 hover:bg-surface-2',
)}
onMouseEnter={() => setShowLocalResearchTooltip(true)}
onMouseLeave={() => setShowLocalResearchTooltip(false)}
@ -110,14 +110,14 @@ const Focus = ({
{/* Web Search Mode Tooltip */}
{showWebSearchTooltip && (
<div className="absolute z-20 bottom-[100%] mb-2 left-0 animate-in fade-in-0 duration-150">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-4 w-80 shadow-lg">
<div className="bg-surface border rounded-lg border-surface-2 p-4 w-80 shadow-lg">
<div className="flex items-center space-x-2 mb-2">
<Globe size={16} className="text-[#24A0ED]" />
<h3 className="font-medium text-sm text-black dark:text-white text-left">
<Globe size={16} className="text-accent" />
<h3 className="font-medium text-sm text-left">
{webSearchMode?.title}
</h3>
</div>
<p className="text-sm text-black/70 dark:text-white/70 leading-relaxed text-left">
<p className="text-sm leading-relaxed text-left">
{webSearchMode?.description}
</p>
</div>
@ -127,14 +127,14 @@ const Focus = ({
{/* Chat Mode Tooltip */}
{showChatTooltip && (
<div className="absolute z-20 bottom-[100%] mb-2 left-0 transform animate-in fade-in-0 duration-150">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-4 w-80 shadow-lg">
<div className="bg-surface border rounded-lg border-surface-2 p-4 w-80 shadow-lg">
<div className="flex items-center space-x-2 mb-2">
<MessageCircle size={16} className="text-[#10B981]" />
<h3 className="font-medium text-sm text-black dark:text-white text-left">
<MessageCircle size={16} className="text-accent" />
<h3 className="font-medium text-sm text-left">
{chatMode?.title}
</h3>
</div>
<p className="text-sm text-black/70 dark:text-white/70 leading-relaxed text-left">
<p className="text-sm leading-relaxed text-left">
{chatMode?.description}
</p>
</div>
@ -144,14 +144,14 @@ const Focus = ({
{/* Local Research Mode Tooltip */}
{showLocalResearchTooltip && (
<div className="absolute z-20 bottom-[100%] mb-2 left-0 animate-in fade-in-0 duration-150">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-4 w-80 shadow-lg">
<div className="bg-surface border rounded-lg border-surface-2 p-4 w-80 shadow-lg">
<div className="flex items-center space-x-2 mb-2">
<Pencil size={16} className="text-[#8B5CF6]" />
<h3 className="font-medium text-sm text-black dark:text-white text-left">
<Pencil size={16} className="text-accent" />
<h3 className="font-medium text-sm text-left">
{localResearchMode?.title}
</h3>
</div>
<p className="text-sm text-black/70 dark:text-white/70 leading-relaxed text-left">
<p className="text-sm leading-relaxed text-left">
{localResearchMode?.description}
</p>
</div>

View file

@ -91,42 +91,7 @@ const ModelSelector = ({
// Sort providers by name (only those that have models)
const sortedProviders = Object.keys(providersData).sort();
setProvidersList(sortedProviders);
// Initialize expanded state for all providers
const initialExpandedState: Record<string, boolean> = {};
sortedProviders.forEach((provider) => {
initialExpandedState[provider] = selectedModel?.provider === provider;
});
// Expand the first provider if none is selected
if (sortedProviders.length > 0 && !selectedModel) {
initialExpandedState[sortedProviders[0]] = true;
}
setExpandedProviders(initialExpandedState);
setProviderModels(providersData);
// Find the current model in our options to display its name
if (selectedModel) {
const provider = providersData[selectedModel.provider];
if (provider) {
const currentModel = provider.models.find(
(option) => option.model === selectedModel.model,
);
if (currentModel) {
setSelectedModelDisplay(currentModel.displayName);
setSelectedProviderDisplay(provider.displayName);
}
} else {
setSelectedModelDisplay('');
setSelectedProviderDisplay('');
}
} else {
setSelectedModelDisplay('');
setSelectedProviderDisplay('');
}
setLoading(false);
} catch (error) {
console.error('Error fetching models:', error);
@ -135,7 +100,56 @@ const ModelSelector = ({
};
fetchModels();
}, [selectedModel]);
// Fetch models once on mount; selection mapping handled in a separate effect
}, []);
// Derive display text from providerModels + selectedModel without clearing on null
useEffect(() => {
if (
!selectedModel ||
!selectedModel.provider ||
!selectedModel.model ||
!providerModels ||
Object.keys(providerModels).length === 0
) {
// Do not clear existing display to prevent flicker
return;
}
const provider = providerModels[selectedModel.provider];
if (!provider) {
console.warn(
`Provider not found: ${selectedModel.provider} available providers: ${JSON.stringify(providerModels)}`,
);
return;
}
const currentModel = provider.models.find(
(option) => option.model === selectedModel.model,
);
if (currentModel) {
setExpandedProviders((prev) => ({
...prev,
[provider.displayName]: !prev[provider.displayName],
}));
setSelectedModelDisplay(currentModel.displayName);
setSelectedProviderDisplay(provider.displayName);
} else {
console.warn(
`Selected model key not found for provider ${selectedModel.provider}: ${selectedModel.model}`,
);
}
}, [providerModels, selectedModel]);
// Expand selected provider once a selection arrives, without collapsing others
useEffect(() => {
if (!selectedModel?.provider) return;
setExpandedProviders((prev) => ({
...prev,
[selectedModel.provider]: true,
}));
}, [selectedModel?.provider]);
const toggleProviderExpanded = (provider: string) => {
setExpandedProviders((prev) => ({
@ -157,7 +171,8 @@ const ModelSelector = ({
};
const getDisplayText = () => {
if (loading) return 'Loading...';
// While models are loading or selection hasn't been determined yet, show Loading to avoid flicker
if (loading || !selectedModel) return 'Loading...';
if (!selectedModelDisplay) return 'Select model';
return `${selectedModelDisplay} (${selectedProviderDisplay})`;
@ -170,7 +185,7 @@ const ModelSelector = ({
<div className="relative">
<PopoverButton
type="button"
className="p-2 group flex text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
className="p-2 group flex text-fg/50 rounded-xl hover:bg-surface-2 active:scale-95 transition duration-200 hover:text-fg"
>
<Cpu size={18} />
{showModelName && (
@ -205,22 +220,22 @@ const ModelSelector = ({
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-10 w-72 transform bottom-full mb-2">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/5 bg-white dark:bg-dark-secondary divide-y divide-light-200 dark:divide-dark-200">
<div className="overflow-hidden rounded-lg shadow-lg bg-surface border border-surface-2 divide-y divide-surface-2">
<div className="px-4 py-3">
<h3 className="text-sm font-medium text-black/90 dark:text-white/90">
<h3 className="text-sm font-medium text-fg/90">
Select Model
</h3>
<p className="text-xs text-black/60 dark:text-white/60 mt-1">
<p className="text-xs text-fg/60 mt-1">
Choose a provider and model for your conversation
</p>
</div>
<div className="max-h-72 overflow-y-auto">
{loading ? (
<div className="px-4 py-3 text-sm text-black/70 dark:text-white/70">
<div className="px-4 py-3 text-sm text-fg/70">
Loading available models...
</div>
) : providersList.length === 0 ? (
<div className="px-4 py-3 text-sm text-black/70 dark:text-white/70">
<div className="px-4 py-3 text-sm text-fg/70">
No models available
</div>
) : (
@ -232,15 +247,15 @@ const ModelSelector = ({
return (
<div
key={providerKey}
className="border-t border-light-200 dark:border-dark-200 first:border-t-0"
className="border-t border-surface-2 first:border-t-0"
>
{/* Provider header */}
<button
className={cn(
'w-full flex items-center justify-between px-4 py-2 text-sm text-left',
'hover:bg-light-100 dark:hover:bg-dark-100',
'hover:bg-surface-2',
selectedModel?.provider === providerKey
? 'bg-light-50 dark:bg-dark-50'
? 'bg-surface-2'
: '',
)}
onClick={() =>
@ -248,13 +263,10 @@ const ModelSelector = ({
}
>
<div className="font-medium flex items-center">
<Cpu
size={14}
className="mr-2 text-black/70 dark:text-white/70"
/>
<Cpu size={14} className="mr-2 text-fg/70" />
{provider.displayName}
{selectedModel?.provider === providerKey && (
<span className="ml-2 text-xs text-[#24A0ED]">
<span className="ml-2 text-xs text-accent">
(active)
</span>
)}
@ -280,8 +292,8 @@ const ModelSelector = ({
modelOption.provider &&
selectedModel?.model ===
modelOption.model
? 'bg-light-100 dark:bg-dark-100 text-black dark:text-white'
: 'text-black/70 dark:text-white/70 hover:bg-light-100 dark:hover:bg-dark-100',
? 'bg-surface-2 text-fg'
: 'text-fg/70 hover:bg-surface-2',
)}
onClick={() =>
handleSelectModel(modelOption)
@ -297,7 +309,7 @@ const ModelSelector = ({
modelOption.provider &&
selectedModel?.model ===
modelOption.model && (
<div className="ml-auto bg-[#24A0ED] text-white text-xs px-1.5 py-0.5 rounded">
<div className="ml-auto bg-accent text-white text-xs px-1.5 py-0.5 rounded">
Active
</div>
)}

View file

@ -8,14 +8,14 @@ const OptimizationModes = [
title: 'Speed',
description:
'Prioritize speed and get the quickest possible answer. Uses only web search results - attached files will not be processed.',
icon: <Zap size={20} className="text-[#FF9800]" />,
icon: <Zap size={20} className="text-accent" />,
},
{
key: 'agent',
title: 'Agent (Experimental)',
description:
'Use an agentic workflow to answer complex multi-part questions. This mode may take longer and is experimental. It uses large prompts and may not work with all models. Best with at least a 8b model that supports 32k context or more.',
icon: <Bot size={20} className="text-[#9C27B0]" />,
'Use an agentic workflow to answer complex multi-part questions. This mode may take longer and is experimental. It requires a model that supports tool calling.',
icon: <Bot size={20} className="text-accent" />,
},
];
@ -47,18 +47,18 @@ const Optimization = ({
<button
type="button"
onClick={handleToggle}
className="text-black/50 dark:text-white/50 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary active:scale-95 transition duration-200 hover:text-black dark:hover:text-white"
className="text-fg/50 rounded-xl hover:bg-surface-2 active:scale-95 transition duration-200 hover:text-fg"
>
<div className="flex flex-row items-center space-x-1">
<div className="relative">
<div className="flex items-center border border-light-200 dark:border-dark-200 rounded-lg overflow-hidden">
<div className="flex items-center border border-surface-2 rounded-lg overflow-hidden">
{/* Speed Mode Icon */}
<div
className={cn(
'p-2 transition-all duration-200',
!isAgentMode
? 'bg-[#FF9800]/20 text-[#FF9800] scale-105'
: 'text-black/30 dark:text-white/30 hover:text-black/50 dark:hover:text-white/50 hover:bg-light-secondary/50 dark:hover:bg-dark-secondary/50',
? 'bg-surface-2 text-accent scale-105'
: 'text-fg/30 hover:text-fg/50 hover:bg-surface-2/50',
)}
onMouseEnter={() => setShowSpeedTooltip(true)}
onMouseLeave={() => setShowSpeedTooltip(false)}
@ -67,15 +67,15 @@ const Optimization = ({
</div>
{/* Divider */}
<div className="h-6 w-px bg-light-200 dark:bg-dark-200"></div>
<div className="h-6 w-px bg-surface-2"></div>
{/* Agent Mode Icon */}
<div
className={cn(
'p-2 transition-all duration-200',
isAgentMode
? 'bg-[#9C27B0]/20 text-[#9C27B0] scale-105'
: 'text-black/30 dark:text-white/30 hover:text-black/50 dark:hover:text-white/50 hover:bg-light-secondary/50 dark:hover:bg-dark-secondary/50',
? 'bg-surface-2 text-accent scale-105'
: 'text-fg/30 hover:text-fg/50 hover:bg-surface-2/50',
)}
onMouseEnter={() => setShowAgentTooltip(true)}
onMouseLeave={() => setShowAgentTooltip(false)}
@ -87,14 +87,14 @@ const Optimization = ({
{/* Speed Mode Tooltip */}
{showSpeedTooltip && (
<div className="absolute z-20 bottom-[100%] mb-2 right-0 animate-in fade-in-0 duration-150">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-4 w-80 shadow-lg">
<div className="bg-surface border rounded-lg border-surface-2 p-4 w-80 shadow-lg">
<div className="flex items-center space-x-2 mb-2">
<Zap size={16} className="text-[#FF9800]" />
<h3 className="font-medium text-sm text-black dark:text-white text-left">
<Zap size={16} className="text-accent" />
<h3 className="font-medium text-sm text-fg text-left">
{speedMode?.title}
</h3>
</div>
<p className="text-sm text-black/70 dark:text-white/70 leading-relaxed text-left">
<p className="text-sm text-fg/70 leading-relaxed text-left">
{speedMode?.description}
</p>
</div>
@ -104,14 +104,14 @@ const Optimization = ({
{/* Agent Mode Tooltip */}
{showAgentTooltip && (
<div className="absolute z-20 bottom-[100%] mb-2 right-0 animate-in fade-in-0 duration-150">
<div className="bg-light-primary dark:bg-dark-primary border rounded-lg border-light-200 dark:border-dark-200 p-4 w-80 shadow-lg">
<div className="bg-surface border rounded-lg border-surface-2 p-4 w-80 shadow-lg">
<div className="flex items-center space-x-2 mb-2">
<Bot size={16} className="text-[#9C27B0]" />
<h3 className="font-medium text-sm text-black dark:text-white text-left">
<Bot size={16} className="text-accent" />
<h3 className="font-medium text-sm text-fg text-left">
{agentMode?.title}
</h3>
</div>
<p className="text-sm text-black/70 dark:text-white/70 leading-relaxed text-left">
<p className="text-sm text-fg/70 leading-relaxed text-left">
{agentMode?.description}
</p>
</div>

View file

@ -88,10 +88,10 @@ const SystemPromptSelector = ({
<>
<PopoverButton
className={cn(
'flex items-center gap-1 rounded-lg text-sm transition-colors duration-150 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500',
'flex items-center gap-1 rounded-lg text-sm transition-colors duration-150 ease-in-out focus:outline-none focus-visible:ring-2',
selectedCount > 0
? 'text-[#24A0ED] hover:text-blue-200'
: 'text-black/60 hover:text-black/30 dark:text-white/60 dark:hover:*:text-white/30',
? 'text-accent hover:text-accent'
: 'text-fg/60 hover:text-fg/30',
)}
title="Select Prompts"
>
@ -109,25 +109,25 @@ const SystemPromptSelector = ({
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-20 w-72 transform bottom-full mb-2">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-black/5 dark:ring-white/5 bg-white dark:bg-dark-secondary">
<div className="px-4 py-3 border-b border-light-200 dark:border-dark-200">
<h3 className="text-sm font-medium text-black/90 dark:text-white/90">
<div className="overflow-hidden rounded-lg shadow-lg ring-1 ring-surface-2 bg-surface">
<div className="px-4 py-3 border-b border-surface-2">
<h3 className="text-sm font-medium text-fg/90">
Select Prompts
</h3>
<p className="text-xs text-black/60 dark:text-white/60 mt-0.5">
<p className="text-xs text-fg/60 mt-0.5">
Choose instructions to guide the AI.
</p>
</div>
{isLoading ? (
<div className="px-4 py-3">
<Loader2 className="animate-spin text-black/70 dark:text-white/70" />
<Loader2 className="animate-spin text-fg/70" />
</div>
) : (
<div className="max-h-60 overflow-y-auto p-1.5 space-y-3">
{availablePrompts.length === 0 && (
<p className="text-xs text-black/50 dark:text-white/50 px-2.5 py-2 text-center">
<p className="text-xs text-fg/50 px-2.5 py-2 text-center">
No prompts configured. <br /> Go to{' '}
<a className="text-blue-500" href="/settings">
<a className="text-accent" href="/settings">
settings
</a>{' '}
to add some.
@ -137,7 +137,7 @@ const SystemPromptSelector = ({
{availablePrompts.filter((p) => p.type === 'system')
.length > 0 && (
<div>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-black/70 dark:text-white/70">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-fg/70">
<Settings size={14} />
<span>System Prompts</span>
</div>
@ -148,21 +148,21 @@ const SystemPromptSelector = ({
<div
key={prompt.id}
onClick={() => handleTogglePrompt(prompt.id)}
className="flex items-center gap-2.5 p-2.5 rounded-md hover:bg-light-100 dark:hover:bg-dark-100 cursor-pointer"
className="flex items-center gap-2.5 p-2.5 rounded-md hover:bg-surface-2 cursor-pointer"
>
{selectedPromptIds.includes(prompt.id) ? (
<CheckSquare
size={18}
className="text-[#24A0ED] flex-shrink-0"
className="text-accent flex-shrink-0"
/>
) : (
<Square
size={18}
className="text-black/40 dark:text-white/40 flex-shrink-0"
className="text-fg/40 flex-shrink-0"
/>
)}
<span
className="text-sm text-black/80 dark:text-white/80 truncate"
className="text-sm text-fg/80 truncate"
title={prompt.name}
>
{prompt.name}
@ -176,7 +176,7 @@ const SystemPromptSelector = ({
{availablePrompts.filter((p) => p.type === 'persona')
.length > 0 && (
<div>
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-black/70 dark:text-white/70">
<div className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-fg/70">
<User size={14} />
<span>Persona Prompts</span>
</div>
@ -187,21 +187,21 @@ const SystemPromptSelector = ({
<div
key={prompt.id}
onClick={() => handleTogglePrompt(prompt.id)}
className="flex items-center gap-2.5 p-2.5 rounded-md hover:bg-light-100 dark:hover:bg-dark-100 cursor-pointer"
className="flex items-center gap-2.5 p-2.5 rounded-md hover:bg-surface-2 cursor-pointer"
>
{selectedPromptIds.includes(prompt.id) ? (
<CheckSquare
size={18}
className="text-[#24A0ED] flex-shrink-0"
className="text-accent flex-shrink-0"
/>
) : (
<Square
size={18}
className="text-black/40 dark:text-white/40 flex-shrink-0"
className="text-fg/40 flex-shrink-0"
/>
)}
<span
className="text-sm text-black/80 dark:text-white/80 truncate"
className="text-sm text-fg/80 truncate"
title={prompt.name}
>
{prompt.name}

View file

@ -0,0 +1,166 @@
import {
Wrench,
ChevronDown,
CheckSquare,
Square,
Loader2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
Popover,
PopoverButton,
PopoverPanel,
Transition,
} from '@headlessui/react';
import { Fragment, useEffect, useState } from 'react';
interface Tool {
name: string;
description: string;
}
interface ToolSelectorProps {
selectedToolNames: string[];
onSelectedToolNamesChange: (names: string[]) => void;
}
const ToolSelector = ({
selectedToolNames,
onSelectedToolNamesChange,
}: ToolSelectorProps) => {
const [availableTools, setAvailableTools] = useState<Tool[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
const fetchTools = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/tools');
if (response.ok) {
const tools = await response.json();
setAvailableTools(tools);
// Check if any currently selected tool names are not in the API response
const availableToolNames = tools.map((tool: Tool) => tool.name);
const validSelectedNames = selectedToolNames.filter((name) =>
availableToolNames.includes(name),
);
// If some selected names are no longer available, update the selection
if (validSelectedNames.length !== selectedToolNames.length) {
onSelectedToolNamesChange(validSelectedNames);
}
} else {
console.error('Failed to load tools.');
}
} catch (error) {
console.error('Error loading tools.');
console.error(error);
} finally {
setIsLoading(false);
}
};
// Only fetch tools once when the component mounts
fetchTools();
}, [selectedToolNames, onSelectedToolNamesChange]);
const handleToggleTool = (toolName: string) => {
const newSelectedNames = selectedToolNames.includes(toolName)
? selectedToolNames.filter((name) => name !== toolName)
: [...selectedToolNames, toolName];
onSelectedToolNamesChange(newSelectedNames);
};
const selectedCount = selectedToolNames.length;
return (
<Popover className="relative">
{({ open }) => (
<>
<PopoverButton
className={cn(
'flex items-center gap-1 rounded-lg text-sm transition-colors duration-150 ease-in-out focus:outline-none focus-visible:ring-2 focus-visible:ring-accent',
selectedCount > 0
? 'text-accent hover:text-accent'
: 'text-fg/60 hover:text-fg/30',
)}
title="Select Tools"
>
<Wrench size={18} />
{selectedCount > 0 ? <span> {selectedCount} </span> : null}
<ChevronDown size={16} className="opacity-60" />
</PopoverButton>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute z-20 w-72 transform bottom-full mb-2">
<div className="overflow-hidden rounded-lg shadow-lg bg-surface border border-surface-2">
<div className="px-4 py-3 border-b border-surface-2">
<h3 className="text-sm font-medium text-fg/90">
Select Tools
</h3>
<p className="text-xs text-fg/60 mt-0.5">
Choose tools to assist the AI.
</p>
</div>
{isLoading ? (
<div className="px-4 py-3">
<Loader2 className="animate-spin text-fg/70" />
</div>
) : (
<div className="max-h-60 overflow-y-auto p-1.5 space-y-0.5">
{availableTools.length === 0 && (
<p className="text-xs text-fg/50 px-2.5 py-2 text-center">
No tools available.
</p>
)}
{availableTools.map((tool) => (
<div
key={tool.name}
onClick={() => handleToggleTool(tool.name)}
className="flex items-start gap-2.5 p-2.5 rounded-md hover:bg-surface-2 cursor-pointer"
>
{selectedToolNames.includes(tool.name) ? (
<CheckSquare
size={18}
className="text-accent flex-shrink-0 mt-0.5"
/>
) : (
<Square
size={18}
className="text-fg/40 flex-shrink-0 mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<span
className="text-sm font-medium text-fg/80 block truncate"
title={tool.name}
>
{tool.name.replace(/_/g, ' ')}
</span>
<p className="text-xs text-fg/60 mt-0.5">
{tool.description}
</p>
</div>
</div>
))}
</div>
)}
</div>
</PopoverPanel>
</Transition>
</>
)}
</Popover>
);
};
export default ToolSelector;

View file

@ -0,0 +1,102 @@
/* eslint-disable @next/next/no-img-element */
import { Document } from '@langchain/core/documents';
import { File, Zap, Microscope, FileText, Sparkles } from 'lucide-react';
interface MessageSourceProps {
source: Document;
index?: number;
style?: React.CSSProperties;
className?: string;
}
const MessageSource = ({
source,
index,
style,
className,
}: MessageSourceProps) => {
return (
<a
className={`bg-surface hover:bg-surface-2 transition duration-200 rounded-lg p-4 flex flex-row no-underline space-x-3 font-medium border border-surface-2 ${className || ''}`}
href={source.metadata.url}
target="_blank"
style={style}
>
{/* Left side: Favicon/Icon and source number */}
<div className="flex flex-col items-center space-y-2 flex-shrink-0">
{source.metadata.url === 'File' ? (
<div className="bg-surface-2 hover:bg-surface transition duration-200 flex items-center justify-center w-8 h-8 rounded-full">
<File size={16} className="text-fg/70" />
</div>
) : (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={28}
height={28}
alt="favicon"
className="rounded-lg h-7 w-7"
/>
)}
<div className="flex flex-row items-center space-x-1 text-fg/50 text-xs">
{typeof index === 'number' && (
<span className="font-semibold">{index + 1}</span>
)}
{/* Processing type indicator */}
{source.metadata.processingType === 'preview-only' && (
<span title="Partial content analyzed" className="inline-flex">
<Zap size={12} className="text-fg/40" />
</span>
)}
{source.metadata.processingType === 'full-content' && (
<span title="Full content analyzed" className="inline-flex">
<Microscope size={12} className="text-fg/40" />
</span>
)}
{source.metadata.processingType === 'url-direct-content' && (
<span title="Direct URL content" className="inline-flex">
<FileText size={12} className="text-fg/40" />
</span>
)}
{source.metadata.processingType === 'url-content-extraction' && (
<span title="Summarized URL content" className="inline-flex">
<Sparkles size={12} className="text-fg/40" />
</span>
)}
</div>
</div>
{/* Right side: Content */}
<div className="flex-1 flex flex-col space-y-2">
{/* Title */}
<h3 className="text-fg text-sm font-semibold leading-tight">
{source.metadata.title}
</h3>
{/* URL */}
<p className="text-xs text-fg/50">
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
</p>
{/* Preview content */}
<p
className="text-xs text-fg/70 leading-relaxed overflow-hidden"
style={{
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
}}
>
{/* Use snippet for preview-only content, otherwise use pageContent */}
{source.metadata.processingType === 'preview-only' &&
source.metadata.snippet
? source.metadata.snippet
: source.pageContent?.length > 250
? source.pageContent.slice(0, 250) + '...'
: source.pageContent || 'No preview available'}
</p>
</div>
</a>
);
};
export default MessageSource;

View file

@ -1,62 +1,12 @@
/* eslint-disable @next/next/no-img-element */
import { Document } from '@langchain/core/documents';
import { File, Zap, Microscope } from 'lucide-react';
import MessageSource from './MessageSource';
const MessageSources = ({ sources }: { sources: Document[] }) => {
return (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-2">
<div className="flex flex-col space-y-3">
{sources.map((source, i) => (
<a
className="bg-light-100 hover:bg-light-200 dark:bg-dark-100 dark:hover:bg-dark-200 transition duration-200 rounded-lg p-3 flex flex-col space-y-2 font-medium"
key={i}
href={source.metadata.url}
target="_blank"
>
<p className="dark:text-white text-xs overflow-hidden whitespace-nowrap text-ellipsis">
{source.metadata.title}
</p>
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row items-center space-x-1">
{source.metadata.url === 'File' ? (
<div className="bg-dark-200 hover:bg-dark-100 transition duration-200 flex items-center justify-center w-6 h-6 rounded-full">
<File size={12} className="text-white/70" />
</div>
) : (
<img
src={`https://s2.googleusercontent.com/s2/favicons?domain_url=${source.metadata.url}`}
width={16}
height={16}
alt="favicon"
className="rounded-lg h-4 w-4"
/>
)}
<p className="text-xs text-black/50 dark:text-white/50 overflow-hidden whitespace-nowrap text-ellipsis">
{source.metadata.url.replace(/.+\/\/|www.|\..+/g, '')}
</p>
</div>
<div className="flex flex-row items-center space-x-1 text-black/50 dark:text-white/50 text-xs">
<div className="bg-black/50 dark:bg-white/50 h-[4px] w-[4px] rounded-full" />
<span>{i + 1}</span>
{/* Processing type indicator */}
{source.metadata.processingType === 'preview-only' && (
<span title="Partial content analyzed" className="inline-flex">
<Zap
size={14}
className="text-black/40 dark:text-white/40 ml-1"
/>
</span>
)}
{source.metadata.processingType === 'full-content' && (
<span title="Full content analyzed" className="inline-flex">
<Microscope
size={14}
className="text-black/40 dark:text-white/40 ml-1"
/>
</span>
)}
</div>
</div>
</a>
<MessageSource key={i} source={source} index={i} />
))}
</div>
);

View file

@ -5,8 +5,6 @@ import { getSuggestions } from '@/lib/actions';
import { cn } from '@/lib/utils';
import {
BookCopy,
CheckCheck,
Copy as CopyIcon,
Disc3,
ImagesIcon,
Layers3,
@ -16,86 +14,16 @@ import {
VideoIcon,
Volume2,
} from 'lucide-react';
import Markdown, { MarkdownToJSX } from 'markdown-to-jsx';
import { useCallback, useEffect, useState } from 'react';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { useSpeech } from 'react-text-to-speech';
import { Message } from './ChatWindow';
import MarkdownRenderer from './MarkdownRenderer';
import Copy from './MessageActions/Copy';
import ModelInfoButton from './MessageActions/ModelInfo';
import Rewrite from './MessageActions/Rewrite';
import MessageSources from './MessageSources';
import SearchImages from './SearchImages';
import SearchVideos from './SearchVideos';
import ThinkBox from './ThinkBox';
const ThinkTagProcessor = ({ children }: { children: React.ReactNode }) => {
return <ThinkBox content={children as string} />;
};
const CodeBlock = ({
className,
children,
}: {
className?: string;
children: React.ReactNode;
}) => {
// Extract language from className (format could be "language-javascript" or "lang-javascript")
let language = '';
if (className) {
if (className.startsWith('language-')) {
language = className.replace('language-', '');
} else if (className.startsWith('lang-')) {
language = className.replace('lang-', '');
}
}
const content = children as string;
const [isCopied, setIsCopied] = useState(false);
const handleCopyCode = () => {
navigator.clipboard.writeText(content);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
};
return (
<div className="rounded-md overflow-hidden my-4 relative group border border-dark-secondary">
<div className="flex justify-between items-center px-4 py-2 bg-dark-200 border-b border-dark-secondary text-xs text-white/70 font-mono">
<span>{language}</span>
<button
onClick={handleCopyCode}
className="p-1 rounded-md hover:bg-dark-secondary transition duration-200"
aria-label="Copy code to clipboard"
>
{isCopied ? (
<CheckCheck size={14} className="text-green-500" />
) : (
<CopyIcon size={14} className="text-white/70" />
)}
</button>
</div>
<SyntaxHighlighter
language={language || 'text'}
style={oneDark}
customStyle={{
margin: 0,
padding: '1rem',
borderRadius: 0,
backgroundColor: '#1c1c1c',
}}
wrapLines={true}
wrapLongLines={true}
showLineNumbers={language !== '' && content.split('\n').length > 1}
useInlineStyles={true}
PreTag="div"
>
{content}
</SyntaxHighlighter>
</div>
);
};
type TabType = 'text' | 'sources' | 'images' | 'videos';
@ -115,6 +43,11 @@ interface SearchTabsProps {
suggestions?: string[];
},
) => void;
onThinkBoxToggle: (
messageId: string,
thinkBoxId: string,
expanded: boolean,
) => void;
}
const MessageTabs = ({
@ -126,6 +59,7 @@ const MessageTabs = ({
loading,
rewrite,
sendMessage,
onThinkBoxToggle,
}: SearchTabsProps) => {
const [activeTab, setActiveTab] = useState<TabType>('text');
const [imageCount, setImageCount] = useState(0);
@ -166,7 +100,6 @@ const MessageTabs = ({
// Process message content
useEffect(() => {
const citationRegex = /\[([^\]]+)\]/g;
const regex = /\[(\d+)\]/g;
let processedMessage = message.content;
@ -185,35 +118,32 @@ const MessageTabs = ({
message.sources.length > 0
) {
setParsedMessage(
processedMessage.replace(
citationRegex,
(_, capturedContent: string) => {
const numbers = capturedContent
.split(',')
.map((numStr) => numStr.trim());
processedMessage.replace(regex, (_, capturedContent: string) => {
const numbers = capturedContent
.split(',')
.map((numStr) => numStr.trim());
const linksHtml = numbers
.map((numStr) => {
const number = parseInt(numStr);
const linksHtml = numbers
.map((numStr) => {
const number = parseInt(numStr);
if (isNaN(number) || number <= 0) {
return `[${numStr}]`;
}
if (isNaN(number) || number <= 0) {
return `[${numStr}]`;
}
const source = message.sources?.[number - 1];
const url = source?.metadata?.url;
const source = message.sources?.[number - 1];
const url = source?.metadata?.url;
if (url) {
return `<a href="${url}" target="_blank" className="bg-light-secondary dark:bg-dark-secondary px-1 rounded ml-1 no-underline text-xs text-black/70 dark:text-white/70 relative">${numStr}</a>`;
} else {
return `[${numStr}]`;
}
})
.join('');
if (url) {
return `<a href="${url}" target="_blank" data-citation="${number}" className="bg-surface px-1 rounded ml-1 no-underline text-xs relative hover:bg-surface-2 transition-colors duration-200">${numStr}</a>`;
} else {
return `[${numStr}]`;
}
})
.join('');
return linksHtml;
},
),
return linksHtml;
}),
);
setSpeechMessage(message.content.replace(regex, ''));
return;
@ -236,44 +166,17 @@ const MessageTabs = ({
}
}, [isLast, loading, message.role, handleLoadSuggestions]);
// Markdown formatting options
const markdownOverrides: MarkdownToJSX.Options = {
overrides: {
think: {
component: ThinkTagProcessor,
},
code: {
component: ({ className, children }) => {
// Check if it's an inline code block or a fenced code block
if (className) {
// This is a fenced code block (```code```)
return <CodeBlock className={className}>{children}</CodeBlock>;
}
// This is an inline code block (`code`)
return (
<code className="px-1.5 py-0.5 rounded bg-dark-secondary text-white font-mono text-sm">
{children}
</code>
);
},
},
pre: {
component: ({ children }) => children,
},
},
};
return (
<div className="flex flex-col w-full">
{/* Tabs */}
<div className="flex border-b border-light-200 dark:border-dark-200 overflow-x-auto no-scrollbar sticky top-0 bg-light-primary dark:bg-dark-primary z-10 -mx-4 px-4 mb-2 shadow-sm">
<div className="flex border-b border-accent overflow-x-auto no-scrollbar sticky top-0 z-10 -mx-4 px-4 mb-2">
<button
onClick={() => setActiveTab('text')}
className={cn(
'flex items-center px-4 py-3 text-sm font-medium transition-all duration-200 relative',
activeTab === 'text'
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
? 'border-b-2 border-accent text-accent bg-surface-2'
: 'hover:bg-surface-2',
)}
aria-selected={activeTab === 'text'}
role="tab"
@ -288,8 +191,8 @@ const MessageTabs = ({
className={cn(
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-all duration-200 relative',
activeTab === 'sources'
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
? 'border-b-2 border-accent text-accent bg-surface-2'
: 'hover:bg-surface-2',
)}
aria-selected={activeTab === 'sources'}
role="tab"
@ -300,8 +203,8 @@ const MessageTabs = ({
className={cn(
'ml-1.5 px-1.5 py-0.5 text-xs rounded-full',
activeTab === 'sources'
? 'bg-[#24A0ED]/20 text-[#24A0ED]'
: 'bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70',
? 'bg-accent/20 text-accent'
: 'bg-surface-2 text-fg/70',
)}
>
{message.sources.length}
@ -314,8 +217,8 @@ const MessageTabs = ({
className={cn(
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-all duration-200 relative',
activeTab === 'images'
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
? 'border-b-2 border-accent text-accent bg-surface-2'
: 'hover:bg-surface-2',
)}
aria-selected={activeTab === 'images'}
role="tab"
@ -327,8 +230,8 @@ const MessageTabs = ({
className={cn(
'ml-1.5 px-1.5 py-0.5 text-xs rounded-full',
activeTab === 'images'
? 'bg-[#24A0ED]/20 text-[#24A0ED]'
: 'bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70',
? 'bg-accent/20 text-accent'
: 'bg-surface-2 text-fg/70',
)}
>
{imageCount}
@ -341,8 +244,8 @@ const MessageTabs = ({
className={cn(
'flex items-center space-x-2 px-4 py-3 text-sm font-medium transition-all duration-200 relative',
activeTab === 'videos'
? 'border-b-2 border-[#24A0ED] text-[#24A0ED] bg-light-100 dark:bg-dark-100'
: 'text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white hover:bg-light-100 dark:hover:bg-dark-100',
? 'border-b-2 border-accent text-accent bg-surface-2'
: 'hover:bg-surface-2',
)}
aria-selected={activeTab === 'videos'}
role="tab"
@ -354,8 +257,8 @@ const MessageTabs = ({
className={cn(
'ml-1.5 px-1.5 py-0.5 text-xs rounded-full',
activeTab === 'videos'
? 'bg-[#24A0ED]/20 text-[#24A0ED]'
: 'bg-light-200 dark:bg-dark-200 text-black/70 dark:text-white/70',
? 'bg-accent/20 text-accent'
: 'bg-surface-2 text-fg/70',
)}
>
{videoCount}
@ -372,20 +275,17 @@ const MessageTabs = ({
{/* Answer Tab */}
{activeTab === 'text' && (
<div className="flex flex-col space-y-4 animate-fadeIn">
<Markdown
className={cn(
'prose prose-h1:mb-3 prose-h2:mb-2 prose-h2:mt-6 prose-h2:font-[800] prose-h3:mt-4 prose-h3:mb-1.5 prose-h3:font-[600] prose-invert prose-p:leading-relaxed prose-pre:p-0 font-[400]',
'prose-code:bg-transparent prose-code:p-0 prose-code:text-inherit prose-code:font-normal prose-code:before:content-none prose-code:after:content-none',
'prose-pre:bg-transparent prose-pre:border-0 prose-pre:m-0 prose-pre:p-0',
'max-w-none break-words px-4 text-black dark:text-white',
)}
options={markdownOverrides}
>
{parsedMessage}
</Markdown>
<MarkdownRenderer
content={parsedMessage}
className="px-4"
messageId={message.messageId}
expandedThinkBoxes={message.expandedThinkBoxes}
onThinkBoxToggle={onThinkBoxToggle}
showThinking={true}
sources={message.sources}
/>{' '}
{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 px-4 py-4">
<div className="flex flex-row items-center space-x-1">
<Rewrite rewrite={rewrite} messageId={message.messageId} />
{message.modelStats && (
@ -402,7 +302,7 @@ const MessageTabs = ({
start();
}
}}
className="p-2 text-black/70 dark:text-white/70 rounded-xl hover:bg-light-secondary dark:hover:bg-dark-secondary transition duration-200 hover:text-black dark:hover:text-white"
className="p-2 opacity-70 rounded-xl hover:bg-surface-2 transition duration-200"
>
{speechStatus === 'started' ? (
<StopCircle size={18} />
@ -413,10 +313,9 @@ const MessageTabs = ({
</div>
</div>
)}
{isLast && message.role === 'assistant' && !loading && (
<>
<div className="border-t border-light-secondary dark:border-dark-secondary px-4 pt-4 mt-4">
<div className="border-t border-surface-2 px-4 pt-4 mt-4">
<div className="flex flex-row items-center space-x-2 mb-3">
<Layers3 size={20} />
<h3 className="text-xl font-medium">Related</h3>
@ -426,10 +325,10 @@ const MessageTabs = ({
<button
onClick={handleLoadSuggestions}
disabled={loadingSuggestions}
className="px-4 py-2 flex flex-row items-center justify-center space-x-2 rounded-lg bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 transition duration-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white"
className="px-4 py-2 flex flex-row items-center justify-center space-x-2 rounded-lg bg-surface hover:bg-surface-2 transition duration-200"
>
{loadingSuggestions ? (
<div className="w-4 h-4 border-2 border-t-transparent border-gray-400 dark:border-gray-500 rounded-full animate-spin" />
<div className="w-4 h-4 border-2 border-t-transparent border-fg/40 rounded-full animate-spin" />
) : (
<Sparkles size={16} />
)}
@ -449,19 +348,19 @@ const MessageTabs = ({
className="flex flex-col space-y-3 text-sm"
key={i}
>
<div className="h-px w-full bg-light-secondary dark:bg-dark-secondary" />
<div className="h-px w-full bg-surface-2" />
<div
onClick={() => {
sendMessage(suggestion);
}}
className="cursor-pointer flex flex-row justify-between font-medium space-x-2 items-center"
>
<p className="transition duration-200 hover:text-[#24A0ED]">
<p className="transition duration-200 hover:text-accent">
{suggestion}
</p>
<Plus
size={20}
className="text-[#24A0ED] flex-shrink-0"
className="text-accent flex-shrink-0"
/>
</div>
</div>
@ -480,23 +379,19 @@ const MessageTabs = ({
message.sources.length > 0 && (
<div className="p-4 animate-fadeIn">
{message.searchQuery && (
<div className="mb-4 text-sm bg-light-secondary dark:bg-dark-secondary rounded-lg p-3">
<span className="font-medium text-black/70 dark:text-white/70">
Search query:
</span>{' '}
<div className="mb-4 text-sm bg-surface rounded-lg p-3">
<span className="font-medium opacity-70">Search query:</span>{' '}
{message.searchUrl ? (
<a
href={message.searchUrl}
target="_blank"
rel="noopener noreferrer"
className="dark:text-white text-black hover:underline"
className="hover:underline"
>
{message.searchQuery}
</a>
) : (
<span className="text-black dark:text-white">
{message.searchQuery}
</span>
<span>{message.searchQuery}</span>
)}
</div>
)}

View file

@ -159,7 +159,7 @@ const Navbar = ({
}, []);
return (
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-[104px] lg:pr-6 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm text-black dark:text-white/70 border-b bg-light-primary dark:bg-dark-primary border-light-100 dark:border-dark-200">
<div className="fixed z-40 top-0 left-0 right-0 px-4 lg:pl-[104px] lg:pr-6 lg:px-8 flex flex-row items-center justify-between w-full py-4 text-sm border-b bg-bg border-surface-2">
<a
href="/"
className="active:scale-95 transition duration-100 cursor-pointer lg:hidden"
@ -174,7 +174,7 @@ const Navbar = ({
<div className="flex flex-row items-center space-x-4">
<Popover className="relative">
<PopoverButton className="active:scale-95 transition duration-100 cursor-pointer p-2 rounded-full hover:bg-light-secondary dark:hover:bg-dark-secondary">
<PopoverButton className="active:scale-95 transition duration-100 cursor-pointer p-2 rounded-full hover:bg-surface-2">
<Share size={17} />
</PopoverButton>
<Transition
@ -186,20 +186,20 @@ const Navbar = ({
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<PopoverPanel className="absolute right-0 mt-2 w-64 rounded-xl shadow-xl bg-light-primary dark:bg-dark-primary border border-light-200 dark:border-dark-200 z-50">
<PopoverPanel className="absolute right-0 mt-2 w-64 rounded-xl shadow-xl bg-surface border border-surface-2 z-50">
<div className="flex flex-col py-3 px-3 gap-2">
<button
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-surface-2 transition-colors rounded-lg font-medium"
onClick={() => exportAsMarkdown(messages, title || '')}
>
<FileText size={17} className="text-[#24A0ED]" />
<FileText size={17} className="text-accent" />
Export as Markdown
</button>
<button
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-light-secondary dark:hover:bg-dark-secondary transition-colors text-black dark:text-white rounded-lg font-medium"
className="flex items-center gap-2 px-4 py-2 text-left hover:bg-surface-2 transition-colors rounded-lg font-medium"
onClick={() => exportAsPDF(messages, title || '')}
>
<FileDown size={17} className="text-[#24A0ED]" />
<FileDown size={17} className="text-accent" />
Export as PDF
</button>
</div>

View file

@ -27,14 +27,14 @@ const NewsArticleWidget = () => {
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-xl border border-light-200 dark:border-dark-200 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3 overflow-hidden">
<div className="bg-surface rounded-xl border border-surface-2 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3 overflow-hidden">
{loading ? (
<>
<div className="animate-pulse flex flex-row items-center w-full h-full">
<div className="rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 bg-light-200 dark:bg-dark-200 mr-3" />
<div className="rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 bg-surface-2 mr-3" />
<div className="flex flex-col justify-center flex-1 h-full w-0 gap-2">
<div className="h-4 w-3/4 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-1/2 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-4 w-3/4 rounded bg-surface-2" />
<div className="h-3 w-1/2 rounded bg-surface-2" />
</div>
</div>
</>
@ -46,7 +46,7 @@ const NewsArticleWidget = () => {
className="flex flex-row items-center w-full h-full group"
>
<img
className="object-cover rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 border border-light-200 dark:border-dark-200 bg-light-200 dark:bg-dark-200 group-hover:opacity-90 transition"
className="object-cover rounded-lg w-16 min-w-16 max-w-16 h-16 min-h-16 max-h-16 border border-surface-2 bg-surface-2 group-hover:opacity-90 transition"
src={
new URL(article.thumbnail).origin +
new URL(article.thumbnail).pathname +
@ -55,10 +55,10 @@ const NewsArticleWidget = () => {
alt={article.title}
/>
<div className="flex flex-col justify-center flex-1 h-full pl-3 w-0">
<div className="font-bold text-xs text-black dark:text-white leading-tight truncate overflow-hidden whitespace-nowrap">
<div className="font-bold text-xs text-fg leading-tight truncate overflow-hidden whitespace-nowrap">
{article.title}
</div>
<p className="text-black/70 dark:text-white/70 text-xs leading-snug truncate overflow-hidden whitespace-nowrap">
<p className="text-fg/70 text-xs leading-snug truncate overflow-hidden whitespace-nowrap">
{article.content}
</p>
</div>

View file

@ -126,7 +126,7 @@ const SearchImages = ({
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
className="bg-surface-2 h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
@ -158,7 +158,7 @@ const SearchImages = ({
<div className="flex justify-center mt-4">
<button
onClick={handleShowMore}
className="px-4 py-2 bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white rounded-md transition duration-200 flex items-center space-x-2"
className="px-4 py-2 bg-surface hover:bg-surface-2 text-fg/70 hover:text-fg rounded-md transition duration-200 flex items-center space-x-2 border border-surface-2"
>
<span>Show More Images</span>
<span className="text-sm opacity-75">

View file

@ -144,7 +144,7 @@ const Searchvideos = ({
{[...Array(4)].map((_, i) => (
<div
key={i}
className="bg-light-secondary dark:bg-dark-secondary h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
className="bg-surface-2 h-32 w-full rounded-lg animate-pulse aspect-video object-cover"
/>
))}
</div>
@ -173,7 +173,7 @@ const Searchvideos = ({
alt={video.title}
className="relative h-full w-full aspect-video object-cover rounded-lg"
/>
<div className="absolute bg-white/70 dark:bg-black/70 text-black/70 dark:text-white/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
<div className="absolute bg-bg/70 text-fg/70 px-2 py-1 flex flex-row items-center space-x-1 bottom-1 right-1 rounded-md">
<PlayCircle size={15} />
<p className="text-xs">Video</p>
</div>
@ -184,7 +184,7 @@ const Searchvideos = ({
<div className="flex justify-center mt-4">
<button
onClick={handleShowMore}
className="px-4 py-2 bg-light-secondary dark:bg-dark-secondary hover:bg-light-200 dark:hover:bg-dark-200 text-black/70 dark:text-white/70 hover:text-black dark:hover:text-white rounded-md transition duration-200 flex items-center space-x-2"
className="px-4 py-2 bg-surface hover:bg-surface-2 text-fg/70 hover:text-fg rounded-md transition duration-200 flex items-center space-x-2 border border-surface-2"
>
<span>Show More Videos</span>
<span className="text-sm opacity-75">

View file

@ -1,7 +1,14 @@
'use client';
import { cn } from '@/lib/utils';
import { BookOpenText, Home, Search, SquarePen, Settings } from 'lucide-react';
import {
BookOpenText,
Home,
Search,
SquarePen,
Settings,
LayoutDashboard,
} from 'lucide-react';
import Link from 'next/link';
import { useSelectedLayoutSegments } from 'next/navigation';
import React, { useState, type ReactNode } from 'react';
@ -23,6 +30,12 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
active: segments.length === 0 || segments.includes('c'),
label: 'Home',
},
{
icon: LayoutDashboard,
href: '/dashboard',
active: segments.includes('dashboard'),
label: 'Dashboard',
},
{
icon: Search,
href: '/discover',
@ -40,7 +53,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
return (
<div>
<div className="hidden lg:fixed lg:inset-y-0 lg:z-50 lg:flex lg:w-20 lg:flex-col">
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-light-secondary dark:bg-dark-secondary px-2 py-8">
<div className="flex grow flex-col items-center justify-between gap-y-5 overflow-y-auto bg-surface px-2 py-8">
<a href="/">
<SquarePen className="cursor-pointer" />
</a>
@ -50,15 +63,13 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
key={i}
href={link.href}
className={cn(
'relative flex flex-row items-center justify-center cursor-pointer hover:bg-black/10 dark:hover:bg-white/10 duration-150 transition w-full py-2 rounded-lg',
link.active
? 'text-black dark:text-white'
: 'text-black/70 dark:text-white/70',
'relative flex flex-row items-center justify-center cursor-pointer hover:bg-surface-2 duration-150 transition w-full py-2 rounded-lg',
link.active ? 'text-fg' : 'text-fg/70',
)}
>
<link.icon />
{link.active && (
<div className="absolute right-0 -mr-2 h-full w-1 rounded-l-lg bg-black dark:bg-white" />
<div className="absolute right-0 -mr-2 h-full w-1 rounded-l-lg bg-accent" />
)}
</Link>
))}
@ -70,20 +81,18 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
</div>
</div>
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-light-primary dark:bg-dark-primary px-4 py-4 shadow-sm lg:hidden">
<div className="fixed bottom-0 w-full z-50 flex flex-row items-center gap-x-6 bg-bg px-4 py-4 shadow-sm lg:hidden">
{navLinks.map((link, i) => (
<Link
href={link.href}
key={i}
className={cn(
'relative flex flex-col items-center space-y-1 text-center w-full',
link.active
? 'text-black dark:text-white'
: 'text-black dark:text-white/70',
link.active ? 'text-fg' : 'text-fg/70',
)}
>
{link.active && (
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-black dark:bg-white" />
<div className="absolute top-0 -mt-4 h-1 w-full rounded-b-lg bg-accent" />
)}
<link.icon />
<p className="text-xs">{link.label}</p>

View file

@ -1,38 +1,47 @@
'use client';
import { useState } from 'react';
import { ReactNode, useState } from 'react';
import { cn } from '@/lib/utils';
import { ChevronDown, ChevronUp, BrainCircuit } from 'lucide-react';
interface ThinkBoxProps {
content: string;
content: ReactNode;
expanded?: boolean;
onToggle?: () => void;
}
const ThinkBox = ({ content }: ThinkBoxProps) => {
const [isExpanded, setIsExpanded] = useState(false);
const ThinkBox = ({ content, expanded, onToggle }: ThinkBoxProps) => {
const [internalExpanded, setInternalExpanded] = useState(false);
// Don't render anything if content is empty
if (!content) {
return null;
}
// Use external expanded state if provided, otherwise use internal state
const isExpanded = expanded !== undefined ? expanded : internalExpanded;
const handleToggle =
onToggle || (() => setInternalExpanded(!internalExpanded));
return (
<div className="my-4 bg-light-secondary/50 dark:bg-dark-secondary/50 rounded-xl border border-light-200 dark:border-dark-200 overflow-hidden">
<div className="my-4 bg-surface/50 rounded-xl border border-surface-2 overflow-hidden">
<button
onClick={() => setIsExpanded(!isExpanded)}
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"
onClick={handleToggle}
className="w-full flex items-center justify-between px-4 py-4 text-fg/90 hover:bg-surface-2 transition duration-200"
>
<div className="flex items-center space-x-2">
<BrainCircuit
size={20}
className="text-[#9C27B0] dark:text-[#CE93D8]"
/>
<p className="font-medium text-sm">Thinking Process</p>
<BrainCircuit size={20} className="text-[#9C27B0]" />
<span className="font-medium text-sm">Thinking Process</span>
</div>
{isExpanded ? (
<ChevronUp size={18} className="text-black/70 dark:text-white/70" />
<ChevronUp size={18} className="text-fg/70" />
) : (
<ChevronDown size={18} className="text-black/70 dark:text-white/70" />
<ChevronDown size={18} className="text-fg/70" />
)}
</button>
{isExpanded && (
<div className="px-4 py-3 text-black/80 dark:text-white/80 text-sm border-t border-light-200 dark:border-dark-200 bg-light-100/50 dark:bg-dark-100/50 whitespace-pre-wrap">
<div className="px-4 py-3 text-fg/80 text-sm border-t border-surface-2 bg-surface/50 whitespace-pre-wrap">
{content}
</div>
)}

View file

@ -10,20 +10,48 @@ const WeatherWidget = () => {
windSpeed: 0,
icon: '',
temperatureUnit: 'C',
windSpeedUnit: 'm/s',
});
const [loading, setLoading] = useState(true);
useEffect(() => {
const getApproxLocation = async () => {
const res = await fetch('https://ipwhois.app/json/');
const data = await res.json();
const IPWHOIS_CACHE_KEY = 'ipwhois_cache_v1';
const IPWHOIS_TTL_MS = 1000 * 60 * 20; // 20 minutes
return {
latitude: data.latitude,
longitude: data.longitude,
city: data.city,
};
const getApproxLocation = async () => {
try {
const raw = sessionStorage.getItem(IPWHOIS_CACHE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (parsed?.ts && Date.now() - parsed.ts < IPWHOIS_TTL_MS && parsed?.data) {
return parsed.data;
}
}
const res = await fetch('https://ipwhois.app/json/');
const data = await res.json();
const payload = {
ts: Date.now(),
data: {
latitude: data.latitude,
longitude: data.longitude,
city: data.city,
},
};
try {
sessionStorage.setItem(IPWHOIS_CACHE_KEY, JSON.stringify(payload));
} catch (e) {
// ignore storage errors (e.g., quota)
}
return payload.data;
} catch (err) {
// If anything goes wrong, fall back to a safe default
return { latitude: 0, longitude: 0, city: '' };
}
};
const getLocation = async (
@ -75,7 +103,7 @@ const WeatherWidget = () => {
body: JSON.stringify({
lat: location.latitude,
lng: location.longitude,
temperatureUnit: localStorage.getItem('temperatureUnit') ?? 'C',
measureUnit: localStorage.getItem('measureUnit') ?? 'Metric',
}),
});
@ -95,28 +123,29 @@ const WeatherWidget = () => {
windSpeed: data.windSpeed,
icon: data.icon,
temperatureUnit: data.temperatureUnit,
windSpeedUnit: data.windSpeedUnit,
});
setLoading(false);
});
}, []);
return (
<div className="bg-light-secondary dark:bg-dark-secondary rounded-xl border border-light-200 dark:border-dark-200 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
<div className="bg-surface rounded-xl border border-surface-2 shadow-sm flex flex-row items-center w-full h-24 min-h-[96px] max-h-[96px] px-3 py-2 gap-3">
{loading ? (
<>
<div className="flex flex-col items-center justify-center w-16 min-w-16 max-w-16 h-full animate-pulse">
<div className="h-10 w-10 rounded-full bg-light-200 dark:bg-dark-200 mb-2" />
<div className="h-4 w-10 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-10 w-10 rounded-full bg-surface-2 mb-2" />
<div className="h-4 w-10 rounded bg-surface-2" />
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1 animate-pulse">
<div className="flex flex-row items-center justify-between">
<div className="h-3 w-20 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-12 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-20 rounded bg-surface-2" />
<div className="h-3 w-12 rounded bg-surface-2" />
</div>
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200 mt-1" />
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200 dark:border-dark-200">
<div className="h-3 w-16 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-8 rounded bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-16 rounded bg-surface-2 mt-1" />
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-surface-2">
<div className="h-3 w-16 rounded bg-surface-2" />
<div className="h-3 w-8 rounded bg-surface-2" />
</div>
</div>
</>
@ -128,24 +157,22 @@ const WeatherWidget = () => {
alt={data.condition}
className="h-10 w-auto"
/>
<span className="text-base font-semibold text-black dark:text-white">
<span className="text-base font-semibold text-fg">
{data.temperature}°{data.temperatureUnit}
</span>
</div>
<div className="flex flex-col justify-between flex-1 h-full py-1">
<div className="flex flex-row items-center justify-between">
<span className="text-xs font-medium text-black dark:text-white">
<span className="text-xs font-medium text-fg">
{data.location}
</span>
<span className="flex items-center text-xs text-black/60 dark:text-white/60">
<span className="flex items-center text-xs text-fg/60">
<Wind className="w-3 h-3 mr-1" />
{data.windSpeed} km/h
{data.windSpeed} {data.windSpeedUnit}
</span>
</div>
<span className="text-xs text-black/60 dark:text-white/60 mt-1">
{data.condition}
</span>
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-light-200 dark:border-dark-200 text-xs text-black/60 dark:text-white/60">
<span className="text-xs text-fg/60 mt-1">{data.condition}</span>
<div className="flex flex-row justify-between w-full mt-auto pt-1 border-t border-surface-2 text-xs text-fg/60">
<span>Humidity: {data.humidity}%</span>
<span>Now</span>
</div>

View file

@ -0,0 +1,534 @@
'use client';
import {
Dialog,
DialogPanel,
DialogTitle,
Transition,
TransitionChild,
Switch,
} from '@headlessui/react';
import { X, Plus, Trash2, Play, Save, Brain } from 'lucide-react';
import { Fragment, useState, useEffect } from 'react';
import MarkdownRenderer from '@/components/MarkdownRenderer';
import ModelSelector from '@/components/MessageInputActions/ModelSelector';
import ToolSelector from '@/components/MessageInputActions/ToolSelector';
import { WidgetConfig, Source } from '@/lib/types/widget';
// 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 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);
const [selectedTools, setSelectedTools] = useState<string[]>([]);
const [showThinking, setShowThinking] = useState(false);
// 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,
});
setSelectedTools(editingWidget.tool_names || []);
} 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',
});
setSelectedTools([]);
}
}, [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
}
// Filter out sources with empty or whitespace-only URLs
const filteredConfig = {
...config,
sources: config.sources.filter((s) => s.url.trim()),
tool_names: selectedTools,
};
onSave(filteredConfig);
handleClose();
};
const handleClose = () => {
setPreviewContent(''); // Clear preview content when closing
onClose();
};
const handlePreview = async () => {
if (!config.prompt.trim()) {
setPreviewContent('Please enter a prompt 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,
tool_names: selectedTools,
}),
});
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={handleClose}>
<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-fg/75" />
</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 lg:max-w-[85vw] transform overflow-hidden rounded-2xl bg-surface p-6 text-left align-middle shadow-xl transition-all">
<DialogTitle
as="h3"
className="text-lg font-medium leading-6 text-fg flex items-center justify-between"
>
{editingWidget ? 'Edit Widget' : 'Create New Widget'}
<button
onClick={handleClose}
className="p-1 hover:bg-surface-2 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-fg 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-surface-2 rounded-md bg-bg text-fg focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Enter widget title..."
/>
</div>
{/* Source URLs */}
<div>
<label className="block text-sm font-medium text-fg 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-surface-2 rounded-md bg-bg text-fg focus:outline-none focus:ring-2 focus:ring-accent"
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-surface-2 rounded-md bg-bg text-fg focus:outline-none focus:ring-2 focus:ring-accent"
>
<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 rounded"
>
<Trash2 size={16} />
</button>
)}
</div>
))}
<button
onClick={addSource}
className="flex items-center gap-2 px-3 py-2 text-sm text-accent hover:bg-surface-2 rounded"
>
<Plus size={16} />
Add Source
</button>
</div>
</div>
{/* LLM Prompt */}
<div>
<label className="block text-sm font-medium text-fg mb-1">
LLM Prompt
</label>
<textarea
value={config.prompt}
onChange={(e) =>
setConfig((prev) => ({
...prev,
prompt: e.target.value,
}))
}
rows={8}
className="w-full px-3 py-2 border border-surface-2 rounded-md bg-bg text-fg focus:outline-none focus:ring-2 focus:ring-accent"
placeholder="Enter your prompt here..."
/>
</div>
{/* Provider and Model Selection */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Model & Provider
</label>
<ModelSelector
selectedModel={selectedModel}
setSelectedModel={setSelectedModel}
truncateModelName={false}
showModelName={true}
/>
<p className="text-xs text-fg/60 mt-1">
Select the AI model and provider to process your widget
content
</p>
</div>
{/* Tool Selection */}
<div>
<label className="block text-sm font-medium text-fg mb-2">
Available Tools
</label>
<ToolSelector
selectedToolNames={selectedTools}
onSelectedToolNamesChange={setSelectedTools}
/>
<p className="text-xs text-fg/60 mt-1">
Select tools to assist the AI in processing your widget.
Your model must support tool calling.
</p>
</div>
{/* Refresh Frequency */}
<div>
<label className="block text-sm font-medium text-fg 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-surface-2 rounded-md bg-bg text-fg focus:outline-none focus:ring-2 focus:ring-accent"
/>
<select
value={config.refreshUnit}
onChange={(e) =>
setConfig((prev) => ({
...prev,
refreshUnit: e.target.value as
| 'minutes'
| 'hours',
}))
}
className="px-3 py-2 border border-surface-2 rounded-md bg-bg text-fg focus:outline-none focus:ring-2 focus:ring-accent"
>
<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-fg">Preview</h4>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2">
<Brain size={16} className="text-fg/70" />
<span className="text-sm text-fg/80">Thinking</span>
<Switch
checked={showThinking}
onChange={setShowThinking}
className="bg-surface border border-surface-2 relative inline-flex h-5 w-10 sm:h-6 sm:w-11 items-center rounded-full"
>
<span className="sr-only">Show thinking tags</span>
<span
className={`${
showThinking
? 'translate-x-6 bg-purple-600'
: 'translate-x-1 bg-fg/50'
} inline-block h-3 w-3 sm:h-4 sm:w-4 transform rounded-full transition-all duration-200`}
/>
</Switch>
</div>
<button
onClick={handlePreview}
disabled={isPreviewLoading}
className="flex items-center gap-2 px-3 py-2 bg-accent text-white rounded hover:bg-accent-700 disabled:opacity-50"
>
<Play size={16} />
{isPreviewLoading ? 'Loading...' : 'Run Preview'}
</button>
</div>
</div>
<div className="h-80 p-4 border border-surface-2 rounded-md bg-surface overflow-y-auto max-w-full">
{previewContent ? (
<div className="prose prose-sm max-w-full">
<MarkdownRenderer
showThinking={showThinking}
content={previewContent}
/>
</div>
) : (
<div className="text-sm text-fg/50 italic">
Click &quot;Run Preview&quot; to see how your widget
will look
</div>
)}
</div>
{/* Variable Legend */}
<div className="text-xs text-fg/70">
<h5 className="font-medium mb-2">Available Variables:</h5>
<div className="space-y-1">
<div>
<code className="bg-surface-2 px-1 rounded">
{'{{current_utc_datetime}}'}
</code>{' '}
- Current UTC date and time
</div>
<div>
<code className="bg-surface-2 px-1 rounded">
{'{{current_local_datetime}}'}
</code>{' '}
- Current local date and time
</div>
<div>
<code className="bg-surface-2 px-1 rounded">
{'{{source_content_1}}'}
</code>{' '}
- Content from first source
</div>
<div>
<code className="bg-surface-2 px-1 rounded">
{'{{source_content_2}}'}
</code>{' '}
- Content from second source
</div>
<div>
<code className="bg-surface-2 px-1 rounded">
{'{{source_content_...}}'}
</code>{' '}
- Content from nth source
</div>
<div>
<code className="bg-surface-2 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={handleClose}
className="px-4 py-2 text-sm font-medium text-fg bg-surface hover:bg-surface-2 rounded-md"
>
Cancel
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-accent hover:bg-accent-700 rounded-md"
>
<Save size={16} />
{editingWidget ? 'Update Widget' : 'Create Widget'}
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
);
};
export default WidgetConfigModal;

View file

@ -0,0 +1,194 @@
'use client';
import {
RefreshCw,
Edit,
Trash2,
Clock,
AlertCircle,
ChevronDown,
ChevronUp,
GripVertical,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import MarkdownRenderer from '@/components/MarkdownRenderer';
import { Widget } from '@/lib/types/widget';
import { useState } from 'react';
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-full w-full">
<CardHeader className="pb-3 flex-shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2 flex-1 min-w-0">
{/* Drag Handle */}
<div
className="widget-drag-handle flex-shrink-0 p-1 rounded hover:bg-surface-2 cursor-move transition-colors"
title="Drag to move widget"
>
<GripVertical size={16} className="text-fg/50" />
</div>
<CardTitle className="text-lg font-medium truncate">
{widget.title}
</CardTitle>
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
{/* Last updated date with refresh frequency tooltip */}
<span
className="text-xs text-fg/60"
title={getRefreshFrequencyText()}
>
{formatLastUpdated(widget.lastUpdated)}
</span>
{/* Refresh button */}
<button
onClick={() => onRefresh(widget.id)}
disabled={widget.isLoading}
className="p-1.5 hover:bg-surface-2 rounded transition-colors disabled:opacity-50"
title="Refresh Widget"
>
<RefreshCw
size={16}
className={cn(
'text-fg/70',
widget.isLoading ? 'animate-spin' : '',
)}
/>
</button>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden">
<div className="h-full overflow-y-auto">
{widget.isLoading ? (
<div className="flex items-center justify-center py-8 text-fg/60">
<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 rounded border border-red-200">
<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">
Error Loading Content
</p>
<p className="text-xs text-red-600 mt-1">{widget.error}</p>
</div>
</div>
) : widget.content ? (
<div className="prose prose-sm max-w-none">
<MarkdownRenderer content={widget.content} showThinking={false} />
</div>
) : (
<div className="flex items-center justify-center py-8 text-fg/60">
<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>
)}
</div>
</CardContent>
{/* Collapsible footer with sources and actions */}
<div className="bg-surface/30 flex-shrink-0">
<button
onClick={() => setIsFooterExpanded(!isFooterExpanded)}
className="w-full px-4 py-2 flex items-center space-x-2 text-xs text-fg/60 hover:bg-surface-2 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-fg/60 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-accent rounded-full"></span>
<span className="text-fg/70 truncate">{source.url}</span>
<span className="text-fg/60">({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-fg/70 hover:bg-surface-2 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-surface-2 rounded transition-colors"
>
<Trash2 size={12} />
<span>Delete</span>
</button>
</div>
</div>
)}
</div>
</Card>
);
};
export default WidgetDisplay;

View file

@ -0,0 +1,209 @@
'use client';
import { useEffect, useState } from 'react';
export type AppTheme = 'light' | 'dark' | 'custom';
type Props = {
children: React.ReactNode;
};
export default function ThemeController({ children }: Props) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
const savedTheme = (localStorage.getItem('appTheme') as AppTheme) || 'dark';
const userBg = localStorage.getItem('userBg') || '';
const userAccent = localStorage.getItem('userAccent') || '';
applyTheme(savedTheme, userBg, userAccent);
}, []);
const applyTheme = (mode: AppTheme, bg?: string, accent?: string) => {
const root = document.documentElement;
root.setAttribute('data-theme', mode);
if (mode === 'custom') {
if (bg) {
root.style.setProperty('--color-bg', normalizeColor(bg));
// decide foreground based on luminance
const luminance = getLuminance(bg);
root.style.setProperty(
'--color-fg',
luminance > 0.5 ? '#000000' : '#ffffff',
);
// surfaces
const surface = adjustLightness(bg, luminance > 0.5 ? -0.06 : 0.08);
const surface2 = adjustLightness(bg, luminance > 0.5 ? -0.1 : 0.12);
root.style.setProperty('--color-surface', surface);
root.style.setProperty('--color-surface-2', surface2);
root.classList.toggle('dark', luminance <= 0.5);
}
if (accent) {
const a600 = normalizeColor(accent);
const a700 = adjustLightness(a600, -0.1);
const a500 = adjustLightness(a600, 0.1);
root.style.setProperty('--color-accent-600', a600);
root.style.setProperty('--color-accent-700', a700);
root.style.setProperty('--color-accent-500', a500);
root.style.setProperty('--color-accent', a600);
// Map default blue to accent to minimize code changes
root.style.setProperty('--color-blue-600', a600);
root.style.setProperty('--color-blue-700', a700);
root.style.setProperty('--color-blue-500', a500);
root.style.setProperty('--color-blue-50', adjustLightness(a600, 0.92));
root.style.setProperty('--color-blue-900', adjustLightness(a600, -0.4));
}
} else {
// Clear any inline custom overrides so stylesheet tokens take effect
const toClear = [
'--user-bg',
'--user-accent',
'--color-bg',
'--color-fg',
'--color-surface',
'--color-surface-2',
'--color-accent-600',
'--color-accent-700',
'--color-accent-500',
'--color-accent',
'--color-blue-600',
'--color-blue-700',
'--color-blue-500',
'--color-blue-50',
'--color-blue-900',
];
toClear.forEach((name) => root.style.removeProperty(name));
root.classList.toggle('dark', mode === 'dark');
}
};
useEffect(() => {
(window as any).__setAppTheme = (
mode: AppTheme,
bg?: string,
accent?: string,
) => {
localStorage.setItem('appTheme', mode);
if (mode === 'custom') {
if (bg) localStorage.setItem('userBg', bg);
if (accent) localStorage.setItem('userAccent', accent);
}
applyTheme(mode, bg, accent);
};
}, []);
if (!mounted) return null;
return <>{children}</>;
}
// helpers
function normalizeColor(c: string): string {
if (c.startsWith('#') && (c.length === 4 || c.length === 7)) return c;
try {
// Attempt to parse rgb(...) or other; create a canvas to normalize
const ctx = document.createElement('canvas').getContext('2d');
if (!ctx) return c;
ctx.fillStyle = c;
const v = ctx.fillStyle as string;
// convert to hex
const m = v.match(/^rgba?\((\d+),\s*(\d+),\s*(\d+)/i);
if (m) {
const r = Number(m[1]),
g = Number(m[2]),
b = Number(m[3]);
return rgbToHex(r, g, b);
}
return c;
} catch {
return c;
}
}
function getLuminance(hex: string): number {
const { r, g, b } = hexToRgb(hex);
const [R, G, B] = [r, g, b].map((v) => {
const srgb = v / 255;
return srgb <= 0.03928
? srgb / 12.92
: Math.pow((srgb + 0.055) / 1.055, 2.4);
});
return 0.2126 * R + 0.7152 * G + 0.0722 * B;
}
function adjustLightness(hex: string, delta: number): string {
// delta in [-1, 1] add to perceived lightness roughly
const { r, g, b } = hexToRgb(hex);
// convert to HSL
let { h, s, l } = rgbToHsl(r, g, b);
l = Math.max(0, Math.min(1, l + delta));
const { r: nr, g: ng, b: nb } = hslToRgb(h, s, l);
return rgbToHex(nr, ng, nb);
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
let h = hex.replace('#', '');
if (h.length === 3)
h = h
.split('')
.map((c) => c + c)
.join('');
const num = parseInt(h, 16);
return { r: (num >> 16) & 255, g: (num >> 8) & 255, b: num & 255 };
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map((v) => v.toString(16).padStart(2, '0')).join('');
}
function rgbToHsl(r: number, g: number, b: number) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b);
let h = 0,
s = 0;
const l = (max + min) / 2;
if (max !== min) {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
break;
}
h /= 6;
}
return { h, s, l };
}
function hslToRgb(h: number, s: number, l: number) {
let r: number, g: number, b: number;
if (s === 0) {
r = g = b = l; // achromatic
} else {
const hue2rgb = (p: number, q: number, t: number) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1 / 6) return p + (q - p) * 6 * t;
if (t < 1 / 2) return q;
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1 / 3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1 / 3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
};
}

View file

@ -1,59 +1,76 @@
'use client';
import { useTheme } from 'next-themes';
import { useCallback, useEffect, useState } from 'react';
import Select from '../ui/Select';
import { useEffect, useState } from 'react';
type Theme = 'dark' | 'light' | 'system';
type Theme = 'dark' | 'light' | 'custom';
const ThemeSwitcher = ({ className }: { className?: string }) => {
const [mounted, setMounted] = useState(false);
const { theme, setTheme } = useTheme();
const isTheme = useCallback((t: Theme) => t === theme, [theme]);
const handleThemeSwitch = (theme: Theme) => {
setTheme(theme);
};
const [theme, setTheme] = useState<Theme>('dark');
const [bg, setBg] = useState<string>('');
const [accent, setAccent] = useState<string>('');
useEffect(() => {
setMounted(true);
const t = (localStorage.getItem('appTheme') as Theme) || 'dark';
const b = localStorage.getItem('userBg') || '#0f0f0f';
const a = localStorage.getItem('userAccent') || '#2563eb';
setTheme(t);
setBg(b);
setAccent(a);
}, []);
useEffect(() => {
if (isTheme('system')) {
const preferDarkScheme = window.matchMedia(
'(prefers-color-scheme: dark)',
);
const detectThemeChange = (event: MediaQueryListEvent) => {
const theme: Theme = event.matches ? 'dark' : 'light';
setTheme(theme);
};
preferDarkScheme.addEventListener('change', detectThemeChange);
return () => {
preferDarkScheme.removeEventListener('change', detectThemeChange);
};
const apply = (next: Theme, nextBg = bg, nextAccent = accent) => {
(window as any).__setAppTheme?.(next, nextBg, nextAccent);
setTheme(next);
if (next === 'light' || next === 'dark') {
// Refresh local color inputs from storage so UI shows current defaults
const b = localStorage.getItem('userBg') || '#0f0f0f';
const a = localStorage.getItem('userAccent') || '#2563eb';
setBg(b);
setAccent(a);
}
}, [isTheme, setTheme, theme]);
};
// Avoid Hydration Mismatch
if (!mounted) {
return null;
}
if (!mounted) return null;
return (
<Select
className={className}
value={theme}
onChange={(e) => handleThemeSwitch(e.target.value as Theme)}
options={[
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
]}
/>
<div className={className}>
<div className="flex gap-2">
<select
className="bg-surface text-fg px-3 py-2 rounded-lg border border-surface-2 text-sm"
value={theme}
onChange={(e) => apply(e.target.value as Theme)}
>
<option value="light">Light</option>
<option value="dark">Dark</option>
<option value="custom">Custom</option>
</select>
{theme === 'custom' && (
<div className="flex items-center gap-2">
<label className="text-xs text-foreground/70">Background</label>
<input
type="color"
value={bg}
onChange={(e) => {
const v = e.target.value;
setBg(v);
apply('custom', v, accent);
}}
/>
<label className="text-xs text-foreground/70">Accent</label>
<input
type="color"
value={accent}
onChange={(e) => {
const v = e.target.value;
setAccent(v);
apply('custom', bg, v);
}}
/>
</div>
)}
</div>
</div>
);
};

View file

@ -10,7 +10,7 @@ export const Select = ({ className, options, ...restProps }: SelectProps) => {
<select
{...restProps}
className={cn(
'bg-light-secondary dark:bg-dark-secondary px-3 py-2 flex items-center overflow-hidden border border-light-200 dark:border-dark-200 dark:text-white rounded-lg text-sm',
'bg-surface px-3 py-2 flex items-center overflow-hidden border border-surface-2 text-fg rounded-lg text-sm',
className,
)}
>

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

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

View file

@ -1,81 +0,0 @@
import { BaseMessage } from '@langchain/core/messages';
import { Annotation, END } from '@langchain/langgraph';
import { Document } from 'langchain/document';
/**
* State interface for the agent supervisor workflow
*/
export const AgentState = Annotation.Root({
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
query: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => '',
}),
relevantDocuments: Annotation<Document[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
bannedSummaryUrls: Annotation<string[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
bannedPreviewUrls: Annotation<string[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
searchInstructionHistory: Annotation<string[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
searchInstructions: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => '',
}),
next: Annotation<string>({
reducer: (x, y) => y ?? x ?? END,
default: () => END,
}),
analysis: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => '',
}),
fullAnalysisAttempts: Annotation<number>({
reducer: (x, y) => (y ?? 0) + x,
default: () => 0,
}),
tasks: Annotation<string[]>({
reducer: (x, y) => y ?? x,
default: () => [],
}),
currentTaskIndex: Annotation<number>({
reducer: (x, y) => y ?? x,
default: () => 0,
}),
originalQuery: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => '',
}),
fileIds: Annotation<string[]>({
reducer: (x, y) => y ?? x,
default: () => [],
}),
focusMode: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => 'webSearch',
}),
urlsToSummarize: Annotation<string[]>({
reducer: (x, y) => y ?? x,
default: () => [],
}),
summarizationIntent: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => '',
}),
recursionLimitReached: Annotation<boolean>({
reducer: (x, y) => y ?? x,
default: () => false,
}),
});

View file

@ -1,360 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import {
AIMessage,
HumanMessage,
SystemMessage,
} from '@langchain/core/messages';
import { ChatPromptTemplate } from '@langchain/core/prompts';
import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { z } from 'zod';
import LineOutputParser from '../outputParsers/lineOutputParser';
import { formatDateForLLM } from '../utils';
import { AgentState } from './agentState';
import { setTemperature } from '../utils/modelUtils';
import {
additionalUserInputPrompt,
additionalWebSearchPrompt,
decideNextActionPrompt,
} from '../prompts/analyzer';
import {
removeThinkingBlocks,
removeThinkingBlocksFromMessages,
} from '../utils/contentUtils';
import { withStructuredOutput } from '../utils/structuredOutput';
import next from 'next';
// Define Zod schemas for structured output
const NextActionSchema = z.object({
action: z
.enum(['good_content', 'need_user_info', 'need_more_info'])
.describe('The next action to take based on content analysis'),
reasoning: z
.string()
.describe('Brief explanation of why this action was chosen'),
});
const UserInfoRequestSchema = z.object({
question: z
.string()
.describe('A detailed question to ask the user for additional information'),
reasoning: z
.string()
.describe('Explanation of why this information is needed'),
});
const SearchRefinementSchema = z.object({
question: z
.string()
.describe('A refined search question to gather more specific information'),
reasoning: z
.string()
.describe(
'Explanation of what information is missing and why this search will help',
),
});
export class AnalyzerAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private systemInstructions: string;
private signal: AbortSignal;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
systemInstructions: string,
signal: AbortSignal,
) {
this.llm = llm;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.signal = signal;
}
async execute(state: typeof AgentState.State): Promise<Command> {
try {
//setTemperature(this.llm, 0.0);
// Initialize originalQuery if not set
if (!state.originalQuery) {
state.originalQuery = state.query;
}
// Check for URLs first - if found and not yet processed, route to URL summarization
if (!state.urlsToSummarize || state.urlsToSummarize.length === 0) {
const urlRegex = /https?:\/\/[^\s]+/gi;
const urls = [...new Set(state.query.match(urlRegex) || [])];
if (urls.length > 0) {
console.log(
'URLs detected in initial query, routing to URL summarization',
);
console.log(`URLs found: ${urls.join(', ')}`);
// Emit URL detection event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URLS_DETECTED_ROUTING',
message: `Detected ${urls.length} URL(s) in query - processing content first`,
details: {
query: state.query,
urls: urls,
},
},
});
return new Command({
goto: 'url_summarization',
update: {
urlsToSummarize: urls,
summarizationIntent: `Process the content from the provided URLs to help answer: ${state.query}`,
},
});
}
}
// Skip full analysis if this is the first run.
//if (state.fullAnalysisAttempts > 0) {
// Emit initial analysis event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ANALYZING_CONTEXT',
message:
'Analyzing the context to see if we have enough information to answer the query',
details: {
documentCount: state.relevantDocuments.length,
query: state.query,
searchIterations: state.searchInstructionHistory.length,
},
},
});
console.log(
`Analyzing ${state.relevantDocuments.length} documents for relevance...`,
);
const nextActionPrompt = await ChatPromptTemplate.fromTemplate(
decideNextActionPrompt,
).format({
systemInstructions: this.systemInstructions,
context: state.relevantDocuments
.map(
(doc, index) =>
`<source${index + 1}>${doc?.metadata?.title ? `<title>${doc?.metadata?.title}</title>` : ''}${doc?.metadata.url ? `<url>${doc?.metadata?.url}</url>` : ''}<content>${doc.pageContent}</content></source${index + 1}>`,
)
.join('\n\n'),
date: formatDateForLLM(new Date()),
searchInstructionHistory: state.searchInstructionHistory
.map((question) => `- ${question}`)
.join('\n'),
query: state.originalQuery || state.query, // Use original query for analysis context
});
const thinkingBlocksRemovedMessages = removeThinkingBlocksFromMessages(
state.messages,
);
// Use structured output for next action decision
const structuredLlm = withStructuredOutput(this.llm, NextActionSchema, {
name: 'analyze_content',
});
const nextActionResponse = await structuredLlm.invoke(
[...thinkingBlocksRemovedMessages, new HumanMessage(nextActionPrompt)],
{ signal: this.signal },
);
console.log('Next action response:', nextActionResponse);
if (nextActionResponse.action !== 'good_content') {
// If we don't have enough information, but we still have available tasks, proceed with the next task
if (state.tasks && state.tasks.length > 0) {
const hasMoreTasks = state.currentTaskIndex < state.tasks.length - 1;
if (hasMoreTasks) {
return new Command({
goto: 'task_manager',
});
}
}
if (nextActionResponse.action === 'need_user_info') {
// Use structured output for user info request
const userInfoLlm = withStructuredOutput(
this.llm,
UserInfoRequestSchema,
{
name: 'request_user_info',
},
);
const moreUserInfoPrompt = await ChatPromptTemplate.fromTemplate(
additionalUserInputPrompt,
).format({
systemInstructions: this.systemInstructions,
context: state.relevantDocuments
.map(
(doc, index) =>
`<source${index + 1}>${doc?.metadata?.title ? `<title>${doc?.metadata?.title}</title>` : ''}<content>${doc.pageContent}</content></source${index + 1}>`,
)
.join('\n\n'),
date: formatDateForLLM(new Date()),
searchInstructionHistory: state.searchInstructionHistory
.map((question) => `- ${question}`)
.join('\n'),
query: state.originalQuery || state.query, // Use original query for user info context
previousAnalysis: nextActionResponse.reasoning, // Include reasoning from previous analysis
});
const userInfoRequest = await userInfoLlm.invoke(
[
...removeThinkingBlocksFromMessages(state.messages),
new HumanMessage(moreUserInfoPrompt),
],
{ signal: this.signal },
);
// Emit the complete question to the user
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: userInfoRequest.question,
}),
);
this.emitter.emit('end');
// Create the final response message with the complete content
const response = new SystemMessage(userInfoRequest.question);
return new Command({
goto: END,
update: {
messages: [response],
},
});
}
// If we need more information from the LLM, generate a more specific search query
// Use structured output for search refinement
const searchRefinementLlm = withStructuredOutput(
this.llm,
SearchRefinementSchema,
{
name: 'refine_search',
},
);
const moreInfoPrompt = await ChatPromptTemplate.fromTemplate(
additionalWebSearchPrompt,
).format({
systemInstructions: this.systemInstructions,
context: state.relevantDocuments
.map(
(doc, index) =>
`<source${index + 1}>${doc?.metadata?.title ? `\n<title>${doc?.metadata?.title}</title>` : ''}\n<content>${doc.pageContent}</content>\n</source${index + 1}>`,
)
.join('\n\n'),
date: formatDateForLLM(new Date()),
searchInstructionHistory: state.searchInstructionHistory
.map((question) => `- ${question}`)
.join('\n'),
query: state.originalQuery || state.query, // Use original query for more info context
previousAnalysis: nextActionResponse.reasoning, // Include reasoning from previous analysis
});
const searchRefinement = await searchRefinementLlm.invoke(
[
...removeThinkingBlocksFromMessages(state.messages),
new HumanMessage(moreInfoPrompt),
],
{ signal: this.signal },
);
// Emit reanalyzing event when we need more information
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'MORE_DATA_NEEDED',
message:
'Current context is insufficient - analyzing search requirements',
details: {
nextSearchQuery: searchRefinement.question,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
query: state.originalQuery || state.query, // Show original query in details
currentSearchFocus: searchRefinement.question,
},
},
});
return new Command({
goto: 'task_manager',
update: {
// messages: [
// new AIMessage(
// `The following question can help refine the search: ${searchRefinement.question}`,
// ),
// ],
query: searchRefinement.question, // Use the refined question for TaskManager to analyze
searchInstructions: searchRefinement.question,
searchInstructionHistory: [
...(state.searchInstructionHistory || []),
searchRefinement.question,
],
fullAnalysisAttempts: 1,
originalQuery: state.originalQuery || state.query, // Preserve the original user query
// Reset task list so TaskManager can break down the search requirements again
tasks: [],
currentTaskIndex: 0,
},
});
}
// Emit information gathering complete event when we have sufficient information
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'INFORMATION_GATHERING_COMPLETE',
message: 'Ready to respond.',
details: {
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
totalTasks: state.tasks?.length || 1,
query: state.originalQuery || state.query,
},
},
});
return new Command({
goto: 'synthesizer',
// update: {
// messages: [
// new AIMessage(
// `Analysis completed. We have sufficient information to answer the query.`,
// ),
// ],
// },
});
} catch (error) {
console.error('Analysis error:', error);
const errorMessage = new AIMessage(
`Analysis failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: END,
update: {
messages: [errorMessage],
},
});
} finally {
setTemperature(this.llm); // Reset temperature for subsequent actions
}
}
}

View file

@ -1,233 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { AIMessage } from '@langchain/core/messages';
import { PromptTemplate } from '@langchain/core/prompts';
import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { z } from 'zod';
import fs from 'node:fs';
import path from 'node:path';
import { AgentState } from './agentState';
import { contentRouterPrompt } from '../prompts/contentRouter';
import { removeThinkingBlocksFromMessages } from '../utils/contentUtils';
import { withStructuredOutput } from '../utils/structuredOutput';
// Define Zod schema for structured router decision output
const RouterDecisionSchema = z.object({
decision: z
.enum(['file_search', 'web_search', 'analyzer'])
.describe('The next step to take in the workflow'),
reasoning: z.string().describe('Explanation of why this decision was made'),
});
type RouterDecision = z.infer<typeof RouterDecisionSchema>;
export class ContentRouterAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private systemInstructions: string;
private signal: AbortSignal;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
systemInstructions: string,
signal: AbortSignal,
) {
this.llm = llm;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.signal = signal;
}
/**
* Content router agent node
*/
async execute(state: typeof AgentState.State): Promise<Command> {
try {
// Determine current task to process
const currentTask =
state.tasks && state.tasks.length > 0
? state.tasks[state.currentTaskIndex || 0]
: state.query;
console.log(
`Content router processing task ${(state.currentTaskIndex || 0) + 1} of ${state.tasks?.length || 1}: "${currentTask}"`,
);
// Extract focus mode from state - this should now come from the API
const focusMode = state.focusMode || 'webSearch';
const hasFiles = state.fileIds && state.fileIds.length > 0;
const documentCount = state.relevantDocuments.length;
const searchHistory = state.searchInstructionHistory.join(', ') || 'None';
// Extract file topics if files are available
const fileTopics = hasFiles
? await this.extractFileTopics(state.fileIds!)
: 'None';
// Emit routing decision event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ROUTING_DECISION',
message: `Determining optimal information source for current task`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
focusMode: focusMode,
hasFiles: hasFiles,
fileCount: state.fileIds?.length || 0,
documentCount: documentCount,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const template = PromptTemplate.fromTemplate(contentRouterPrompt);
const prompt = await template.format({
systemInstructions: this.systemInstructions,
currentTask: currentTask,
query: state.originalQuery || state.query,
focusMode: focusMode,
hasFiles: hasFiles,
fileTopics: fileTopics,
documentCount: documentCount,
searchHistory: searchHistory,
});
// Use structured output for routing decision
const structuredLlm = withStructuredOutput(
this.llm,
RouterDecisionSchema,
{
name: 'route_content',
},
);
const routerDecision = (await structuredLlm.invoke(
[...removeThinkingBlocksFromMessages(state.messages), prompt],
{ signal: this.signal },
)) as RouterDecision;
console.log(`Router decision: ${routerDecision.decision}`);
console.log(`Router reasoning: ${routerDecision.reasoning}`);
console.log(`File topics: ${fileTopics}`);
console.log(`Focus mode: ${focusMode}`);
// Validate decision based on focus mode restrictions
const validatedDecision = this.validateDecision(
routerDecision,
focusMode,
hasFiles,
);
// Emit routing result event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ROUTING_RESULT',
message: `Routing to ${validatedDecision.decision}: ${validatedDecision.reasoning}`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
decision: validatedDecision.decision,
focusMode: focusMode,
hasFiles: hasFiles,
documentCount: documentCount,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const responseMessage = `Content routing completed. Next step: ${validatedDecision.decision}`;
console.log(responseMessage);
return new Command({
goto: validatedDecision.decision,
// update: {
// messages: [new AIMessage(responseMessage)],
// },
});
} catch (error) {
console.error('Content router error:', error);
const errorMessage = new AIMessage(
`Content routing failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: END,
update: {
messages: [errorMessage],
},
});
}
}
/**
* Extract semantic topics from attached files for relevance assessment
*/
private async extractFileTopics(fileIds: string[]): Promise<string> {
try {
const topics = fileIds.map((fileId) => {
try {
const filePath = path.join(process.cwd(), 'uploads', fileId);
const contentPath = filePath + '-extracted.json';
if (fs.existsSync(contentPath)) {
const content = JSON.parse(fs.readFileSync(contentPath, 'utf8'));
const filename = content.title || 'Document';
// Use LLM-generated semantic topics if available, otherwise fall back to filename
const semanticTopics = content.topics;
return semanticTopics || filename;
}
return 'Unknown Document';
} catch (error) {
console.warn(`Error extracting topic for file ${fileId}:`, error);
return 'Unknown Document';
}
});
return topics.join('; ');
} catch (error) {
console.warn('Error extracting file topics:', error);
return 'Unable to determine file topics';
}
}
/**
* Validate and potentially override the router decision based on focus mode restrictions
*/
private validateDecision(
decision: RouterDecision,
focusMode: string,
hasFiles: boolean,
): RouterDecision {
// Enforce focus mode restrictions for chat and localResearch modes
if (
(focusMode === 'chat' || focusMode === 'localResearch') &&
decision.decision === 'web_search'
) {
// Override to file_search if files are available, otherwise analyzer
const fallbackDecision = hasFiles ? 'file_search' : 'analyzer';
console.log(
`Overriding web_search decision to ${fallbackDecision} due to focus mode restriction: ${focusMode}`,
);
return {
decision: fallbackDecision as 'file_search' | 'analyzer',
reasoning: `Overridden to ${fallbackDecision} - web search not allowed in ${focusMode} mode. ${decision.reasoning}`,
};
}
// For webSearch mode, trust the LLM's decision about file relevance
// No overrides needed - the enhanced prompt handles file relevance assessment
return decision;
}
}

View file

@ -1,238 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { AIMessage } from '@langchain/core/messages';
import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { Document } from 'langchain/document';
import { AgentState } from './agentState';
import { Embeddings } from '@langchain/core/embeddings';
import {
processFilesToDocuments,
getRankedDocs,
} from '../utils/fileProcessing';
export class FileSearchAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private systemInstructions: string;
private signal: AbortSignal;
private embeddings: Embeddings;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
systemInstructions: string,
signal: AbortSignal,
embeddings: Embeddings,
) {
this.llm = llm;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.signal = signal;
this.embeddings = embeddings;
}
/**
* File search agent node
*/
async execute(state: typeof AgentState.State): Promise<Command> {
try {
// Determine current task to process
const currentTask =
state.tasks && state.tasks.length > 0
? state.tasks[state.currentTaskIndex || 0]
: state.query;
console.log(
`Processing file search for task ${(state.currentTaskIndex || 0) + 1} of ${state.tasks?.length || 1}: "${currentTask}"`,
);
// Check if we have file IDs to process
if (!state.fileIds || state.fileIds.length === 0) {
console.log('No files attached for search');
return new Command({
goto: 'analyzer',
update: {
messages: [new AIMessage('No files attached to search.')],
},
});
}
// Emit consulting attached files event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'CONSULTING_ATTACHED_FILES',
message: `Consulting attached files...`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
fileCount: state.fileIds.length,
documentCount: state.relevantDocuments.length,
},
},
});
// Process files to documents
const fileDocuments = await processFilesToDocuments(state.fileIds);
if (fileDocuments.length === 0) {
console.log('No processable file content found');
return new Command({
goto: 'analyzer',
// update: {
// messages: [
// new AIMessage('No searchable content found in attached files.'),
// ],
// },
});
}
console.log(
`Processed ${fileDocuments.length} file documents for search`,
);
// Emit searching file content event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'SEARCHING_FILE_CONTENT',
message: `Searching through ${fileDocuments.length} file sections for relevant information`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
fileDocumentCount: fileDocuments.length,
documentCount: state.relevantDocuments.length,
},
},
});
// Generate query embedding for similarity search
const queryEmbedding = await this.embeddings.embedQuery(
state.originalQuery + ' ' + currentTask,
);
// Perform similarity search over file documents
const rankedDocuments = getRankedDocs(
queryEmbedding,
fileDocuments,
12, // maxDocs
0.3, // similarity threshold
);
console.log(`Found ${rankedDocuments.length} relevant file sections`);
if (rankedDocuments.length === 0) {
// Emit no relevant content event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'NO_RELEVANT_FILE_CONTENT',
message: `No relevant content found in attached files for the current task`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
searchedDocuments: fileDocuments.length,
documentCount: state.relevantDocuments.length,
},
},
});
return new Command({
goto: 'analyzer',
// update: {
// messages: [
// new AIMessage(
// 'No relevant content found in attached files for the current task.',
// ),
// ],
// },
});
}
// Emit file content found event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'FILE_CONTENT_FOUND',
message: `Found ${rankedDocuments.length} relevant sections in attached files`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
relevantSections: rankedDocuments.length,
searchedDocuments: fileDocuments.length,
documentCount:
state.relevantDocuments.length + rankedDocuments.length,
},
},
});
const responseMessage = `File search completed. Found ${rankedDocuments.length} relevant sections in attached files.`;
console.log(responseMessage);
return new Command({
goto: 'analyzer', // Route back to analyzer to process the results
update: {
// messages: [new AIMessage(responseMessage)],
relevantDocuments: rankedDocuments,
},
});
} catch (error) {
console.error('File search error:', error);
const errorMessage = new AIMessage(
`File search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: END,
update: {
messages: [errorMessage],
},
});
}
}
/**
* Perform a similarity search over file documents
* @param state The current agent state
* @returns Ranked documents relevant to the current task
*/
async search(state: typeof AgentState.State): Promise<Document[]> {
if (!state.fileIds || state.fileIds.length === 0) {
return [];
}
// Process files to documents
const fileDocuments = await processFilesToDocuments(state.fileIds);
if (fileDocuments.length === 0) {
return [];
}
// Determine current task to search for
const currentTask =
state.tasks && state.tasks.length > 0
? state.tasks[state.currentTaskIndex || 0]
: state.query;
// Generate query embedding for similarity search
const queryEmbedding = await this.embeddings.embedQuery(
state.originalQuery + ' ' + currentTask,
);
// Perform similarity search and return ranked documents
return getRankedDocs(
queryEmbedding,
fileDocuments,
8, // maxDocs
0.3, // similarity threshold
);
}
}

View file

@ -1,8 +0,0 @@
export { AgentState } from './agentState';
export { WebSearchAgent } from './webSearchAgent';
export { AnalyzerAgent } from './analyzerAgent';
export { SynthesizerAgent } from './synthesizerAgent';
export { TaskManagerAgent } from './taskManagerAgent';
export { FileSearchAgent } from './fileSearchAgent';
export { ContentRouterAgent } from './contentRouterAgent';
export { URLSummarizationAgent } from './urlSummarizationAgent';

View file

@ -1,165 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { HumanMessage, SystemMessage } from '@langchain/core/messages';
import { PromptTemplate } from '@langchain/core/prompts';
import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { getModelName } from '../utils/modelUtils';
import { AgentState } from './agentState';
import { removeThinkingBlocksFromMessages } from '../utils/contentUtils';
import { synthesizerPrompt } from '../prompts/synthesizer';
export class SynthesizerAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private personaInstructions: string;
private signal: AbortSignal;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
personaInstructions: string,
signal: AbortSignal,
) {
this.llm = llm;
this.emitter = emitter;
this.personaInstructions = personaInstructions;
this.signal = signal;
}
/**
* Synthesizer agent node that combines information to answer the query
*/
async execute(state: typeof AgentState.State): Promise<Command> {
try {
// Format the prompt using the external template
const template = PromptTemplate.fromTemplate(synthesizerPrompt);
const conversationHistory =
removeThinkingBlocksFromMessages(state.messages)
.map((msg) => `<${msg.getType()}>${msg.content}</${msg.getType()}>`)
.join('\n') || 'No previous conversation context';
const relevantDocuments = state.relevantDocuments
.map((doc, index) => {
const isFile = doc.metadata?.url?.toLowerCase().includes('file');
return `<${index + 1}>\n
<title>${doc.metadata.title}</title>
<source_type>${isFile ? 'file' : 'web'}</source_type>
${isFile ? '' : '\n<url>' + doc.metadata.url + '</url>'}
<content>\n${doc.pageContent}\n </content>
</${index + 1}>`;
})
.join('\n');
const recursionLimitMessage = state.recursionLimitReached
? `# ⚠️ IMPORTANT NOTICE - LIMITED INFORMATION
**The search process was interrupted due to complexity limits. You MUST start your response with a warning about incomplete information and qualify all statements appropriately.**
## CRITICAL: Incomplete Information Response Requirements
**You MUST:**
1. **Start your response** with a clear warning that the information may be incomplete or conflicting
2. **Acknowledge limitations** throughout your response where information gaps exist
3. **Be transparent** about what you cannot determine from the available sources
4. **Suggest follow-up actions** for the user to get more complete information
5. **Qualify your statements** with phrases like "based on available information" or "from the limited sources gathered"
**Example opening for incomplete information responses:**
" **Please note:** This response is based on incomplete information due to search complexity limits. The findings below may be missing important details or conflicting perspectives. I recommend verifying this information through additional research or rephrasing your query for better results.
`
: '';
// If we have limited documents due to recursion limit, acknowledge this
const documentsAvailable = state.relevantDocuments?.length || 0;
const limitedInfoNote =
state.recursionLimitReached && documentsAvailable === 0
? '**CRITICAL: No source documents were gathered due to search limitations.**\n\n'
: state.recursionLimitReached
? `**NOTICE: Search was interrupted with ${documentsAvailable} documents gathered.**\n\n`
: '';
const formattedPrompt = await template.format({
personaInstructions: this.personaInstructions,
conversationHistory: conversationHistory,
relevantDocuments: relevantDocuments,
query: state.originalQuery || state.query,
recursionLimitReached: recursionLimitMessage + limitedInfoNote,
});
// Stream the response in real-time using LLM streaming capabilities
let fullResponse = '';
// Emit the sources as a data response
this.emitter.emit(
'data',
JSON.stringify({
type: 'sources',
data: state.relevantDocuments,
searchQuery: '',
searchUrl: '',
}),
);
const stream = await this.llm.stream(
[
new SystemMessage(formattedPrompt),
new HumanMessage(state.originalQuery || state.query),
],
{ signal: this.signal },
);
for await (const chunk of stream) {
if (this.signal.aborted) {
break;
}
const content = chunk.content;
if (typeof content === 'string' && content.length > 0) {
fullResponse += content;
// Emit each chunk as a data response in real-time
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: content,
}),
);
}
}
// Emit model stats and end signal after streaming is complete
const modelName = getModelName(this.llm);
this.emitter.emit(
'stats',
JSON.stringify({
type: 'modelStats',
data: { modelName },
}),
);
this.emitter.emit('end');
// Create the final response message with the complete content
const response = new SystemMessage(fullResponse);
return new Command({
goto: END,
update: {
messages: [response],
},
});
} catch (error) {
console.error('Synthesis error:', error);
const errorMessage = new SystemMessage(
`Failed to synthesize answer: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: END,
update: {
messages: [errorMessage],
},
});
}
}
}

View file

@ -1,225 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { AIMessage } from '@langchain/core/messages';
import { PromptTemplate } from '@langchain/core/prompts';
import { Command } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { z } from 'zod';
import { taskBreakdownPrompt } from '../prompts/taskBreakdown';
import { AgentState } from './agentState';
import { setTemperature } from '../utils/modelUtils';
import { withStructuredOutput } from '../utils/structuredOutput';
// Define Zod schema for structured task breakdown output
const TaskBreakdownSchema = z.object({
tasks: z
.array(z.string())
.describe(
'Array of specific, focused tasks broken down from the original query',
),
reasoning: z
.string()
.describe(
'Explanation of how and why the query was broken down into these tasks',
),
});
type TaskBreakdown = z.infer<typeof TaskBreakdownSchema>;
export class TaskManagerAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private systemInstructions: string;
private signal: AbortSignal;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
systemInstructions: string,
signal: AbortSignal,
) {
this.llm = llm;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.signal = signal;
}
/**
* Task manager agent node - breaks down complex questions into smaller tasks
*/
async execute(state: typeof AgentState.State): Promise<Command> {
try {
//setTemperature(this.llm, 0); // Set temperature to 0 for deterministic output
// Check if we're in task progression mode (tasks already exist and we're processing them)
if (state.tasks && state.tasks.length > 0) {
const currentTaskIndex = state.currentTaskIndex || 0;
const hasMoreTasks = currentTaskIndex < state.tasks.length - 1;
if (hasMoreTasks) {
// Move to next task
const nextTaskIndex = currentTaskIndex + 1;
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PROCEEDING_TO_NEXT_TASK',
message: `Task ${currentTaskIndex + 1} completed. Moving to task ${nextTaskIndex + 1} of ${state.tasks.length}.`,
details: {
completedTask: state.tasks[currentTaskIndex],
nextTask: state.tasks[nextTaskIndex],
taskIndex: nextTaskIndex + 1,
totalTasks: state.tasks.length,
documentCount: state.relevantDocuments.length,
query: state.originalQuery || state.query,
},
},
});
return new Command({
goto: 'content_router',
update: {
// messages: [
// new AIMessage(
// `Task ${currentTaskIndex + 1} completed. Processing task ${nextTaskIndex + 1} of ${state.tasks.length}: "${state.tasks[nextTaskIndex]}"`,
// ),
// ],
currentTaskIndex: nextTaskIndex,
},
});
} else {
// All tasks completed, move to analysis
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ALL_TASKS_COMPLETED',
message: `All ${state.tasks.length} tasks completed. Ready for analysis.`,
details: {
totalTasks: state.tasks.length,
documentCount: state.relevantDocuments.length,
query: state.originalQuery || state.query,
},
},
});
return new Command({
goto: 'analyzer',
// update: {
// messages: [
// new AIMessage(
// `All ${state.tasks.length} tasks completed. Moving to analysis phase.`,
// ),
// ],
// },
});
}
}
// Original task breakdown logic for new queries
// Emit task analysis event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ANALYZING_TASK_COMPLEXITY',
message: `Analyzing question to determine if it needs to be broken down into smaller tasks`,
details: {
query: state.query,
currentTasks: state.tasks?.length || 0,
},
},
});
const template = PromptTemplate.fromTemplate(taskBreakdownPrompt);
// Create file context information
const fileContext =
state.fileIds && state.fileIds.length > 0
? `Files attached: ${state.fileIds.length} file(s) are available for analysis. Consider creating tasks that can leverage these attached files when appropriate.`
: 'No files attached: Focus on tasks that can be answered through web research or general knowledge.';
const prompt = await template.format({
systemInstructions: this.systemInstructions,
fileContext: fileContext,
query: state.query,
});
// Use structured output for task breakdown
const structuredLlm = withStructuredOutput(
this.llm,
TaskBreakdownSchema,
{
name: 'break_down_tasks',
},
);
const taskBreakdownResult = (await structuredLlm.invoke([prompt], {
signal: this.signal,
})) as TaskBreakdown;
console.log('Task breakdown response:', taskBreakdownResult);
// Extract tasks from structured response
const taskLines = taskBreakdownResult.tasks.filter(
(task) => task.trim().length > 0,
);
if (taskLines.length === 0) {
// Fallback: if no tasks found, use the original query
taskLines.push(state.query);
}
console.log(
`Task breakdown completed: ${taskLines.length} tasks identified`,
);
console.log('Reasoning:', taskBreakdownResult.reasoning);
taskLines.forEach((task, index) => {
console.log(`Task ${index + 1}: ${task}`);
});
// Emit task breakdown completion event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'TASK_BREAKDOWN_COMPLETED',
message: `Question broken down into ${taskLines.length} focused ${taskLines.length === 1 ? 'task' : 'tasks'}`,
details: {
query: state.query,
taskCount: taskLines.length,
tasks: taskLines,
reasoning: taskBreakdownResult.reasoning,
},
},
});
const responseMessage =
taskLines.length === 1
? 'Question is already focused and ready for processing'
: `Question broken down into ${taskLines.length} focused tasks for parallel processing`;
return new Command({
goto: 'content_router', // Route to content router to decide between file search, web search, or analysis
update: {
// messages: [new AIMessage(responseMessage)],
tasks: taskLines,
currentTaskIndex: 0,
originalQuery: state.originalQuery || state.query, // Preserve original if not already set
},
});
} catch (error) {
console.error('Task breakdown error:', error);
const errorMessage = new AIMessage(
`Task breakdown failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: 'content_router', // Fallback to content router with original query
update: {
messages: [errorMessage],
tasks: [state.query], // Use original query as single task
currentTaskIndex: 0,
originalQuery: state.originalQuery || state.query, // Preserve original if not already set
},
});
} finally {
setTemperature(this.llm, undefined); // Reset temperature to default
}
}
}

View file

@ -1,300 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { AIMessage } from '@langchain/core/messages';
import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { Document } from 'langchain/document';
import { AgentState } from './agentState';
import { getWebContent } from '../utils/documents';
import { removeThinkingBlocks } from '../utils/contentUtils';
import { setTemperature } from '../utils/modelUtils';
export class URLSummarizationAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private systemInstructions: string;
private signal: AbortSignal;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
systemInstructions: string,
signal: AbortSignal,
) {
this.llm = llm;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.signal = signal;
}
/**
* URL processing agent node
*/
async execute(state: typeof AgentState.State): Promise<Command> {
try {
setTemperature(this.llm, 0); // Set temperature to 0 for deterministic output
// Use pre-analyzed URLs from ContentRouterAgent
const urlsToProcess = state.urlsToSummarize || [];
const summarizationIntent =
state.summarizationIntent ||
'process content to help answer the user query';
if (urlsToProcess.length === 0) {
console.log(
'No URLs found for processing, routing back to content router',
);
return new Command({
goto: 'content_router',
// update: {
// messages: [
// new AIMessage(
// 'No URLs found for processing, routing to content router',
// ),
// ],
// },
});
}
console.log(`URL processing detected. URLs: ${urlsToProcess.join(', ')}`);
console.log(`Processing intent: ${summarizationIntent}`);
// Emit URL detection event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URL_PROCESSING_DETECTED',
message: `Processing ${urlsToProcess.length} URL(s) to extract content for analysis`,
details: {
query: state.query,
urls: urlsToProcess,
intent: summarizationIntent,
},
},
});
const documents: Document[] = [];
// Process each URL
for (const url of urlsToProcess) {
if (this.signal.aborted) {
console.warn('URL summarization operation aborted by signal');
break;
}
try {
// Emit URL processing event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PROCESSING_URL',
message: `Retrieving and processing content from: ${url}`,
details: {
query: state.query,
sourceUrl: url,
intent: summarizationIntent,
},
},
});
// Fetch full content using the enhanced web content retrieval
const webContent = await getWebContent(url, true);
if (!webContent || !webContent.pageContent) {
console.warn(`No content retrieved from URL: ${url}`);
// Emit URL processing failure event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URL_PROCESSING_FAILED',
message: `Failed to retrieve content from: ${url}`,
details: {
query: state.query,
sourceUrl: url,
reason: 'No content retrieved',
},
},
});
continue;
}
const contentLength = webContent.pageContent.length;
let finalContent: string;
let processingType: string;
// If content is short (< 4000 chars), use it directly; otherwise summarize
if (contentLength < 4000) {
finalContent = webContent.pageContent;
processingType = 'url-direct-content';
console.log(
`Content is short (${contentLength} chars), using directly without summarization`,
);
// Emit direct content usage event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URL_DIRECT_CONTENT',
message: `Content is short (${contentLength} chars), using directly from: ${url}`,
details: {
query: state.query,
sourceUrl: url,
sourceTitle: webContent.metadata.title || 'Web Page',
contentLength: contentLength,
intent: summarizationIntent,
},
},
});
} else {
// Content is long, summarize using LLM
console.log(
`Content is long (${contentLength} chars), generating summary`,
);
const systemPrompt = this.systemInstructions
? `${this.systemInstructions}\n\n`
: '';
const summarizationPrompt = `${systemPrompt}You are a web content processor. Extract and summarize ONLY the information from the provided web page content that is relevant to the user's query.
# Critical Instructions
- Output ONLY a summary of the web page content provided below
- Focus on information that relates to or helps answer the user's query
- Do NOT add pleasantries, greetings, or conversational elements
- Do NOT mention missing URLs, other pages, or content not provided
- Do NOT ask follow-up questions or suggest additional actions
- Do NOT add commentary about the user's request or query
- Present the information in a clear, well-structured format with key facts and details
- Include all relevant details that could help answer the user's question
# User's Query: ${state.query}
# Content Title: ${webContent.metadata.title || 'Web Page'}
# Content URL: ${url}
# Web Page Content to Summarize:
${webContent.pageContent}
Provide a comprehensive summary of the above web page content, focusing on information relevant to the user's query:`;
const result = await this.llm.invoke(summarizationPrompt, {
signal: this.signal,
});
finalContent = removeThinkingBlocks(result.content as string);
processingType = 'url-content-extraction';
}
if (finalContent && finalContent.trim().length > 0) {
const document = new Document({
pageContent: finalContent,
metadata: {
title: webContent.metadata.title || 'URL Content',
url: url,
source: url,
processingType: processingType,
processingIntent: summarizationIntent,
originalContentLength: contentLength,
},
});
documents.push(document);
// Emit successful URL processing event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URL_CONTENT_EXTRACTED',
message: `Successfully processed content from: ${url}`,
details: {
query: state.query,
sourceUrl: url,
sourceTitle: webContent.metadata.title || 'Web Page',
contentLength: finalContent.length,
originalContentLength: contentLength,
processingType: processingType,
intent: summarizationIntent,
},
},
});
console.log(
`Successfully processed content from ${url} (${finalContent.length} characters, ${processingType})`,
);
} else {
console.warn(`No valid content generated for URL: ${url}`);
}
} catch (error) {
console.error(`Error processing URL ${url}:`, error);
// Emit URL processing error event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URL_PROCESSING_ERROR',
message: `Error processing URL: ${url}`,
details: {
query: state.query,
sourceUrl: url,
error: error instanceof Error ? error.message : 'Unknown error',
},
},
});
}
}
if (documents.length === 0) {
const errorMessage = `No content could be retrieved or summarized from the provided URL(s): ${urlsToProcess.join(', ')}`;
console.error(errorMessage);
return new Command({
goto: 'analyzer',
// update: {
// messages: [new AIMessage(errorMessage)],
// },
});
}
// Emit completion event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'URL_PROCESSING_COMPLETED',
message: `Successfully processed ${documents.length} URL(s) and extracted content`,
details: {
query: state.query,
processedUrls: urlsToProcess.length,
successfulExtractions: documents.length,
intent: summarizationIntent,
},
},
});
const responseMessage = `URL processing completed. Successfully processed ${documents.length} out of ${urlsToProcess.length} URLs.`;
console.log(responseMessage);
return new Command({
goto: 'analyzer', // Route to analyzer to continue with normal workflow after URL processing
update: {
// messages: [new AIMessage(responseMessage)],
relevantDocuments: documents,
},
});
} catch (error) {
console.error('URL summarization error:', error);
const errorMessage = new AIMessage(
`URL summarization failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: END,
update: {
messages: [errorMessage],
},
});
} finally {
setTemperature(this.llm, undefined); // Reset temperature to default
}
}
}

View file

@ -1,461 +0,0 @@
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { AIMessage } from '@langchain/core/messages';
import { PromptTemplate } from '@langchain/core/prompts';
import { Command, END } from '@langchain/langgraph';
import { EventEmitter } from 'events';
import { Document } from 'langchain/document';
import { z } from 'zod';
import LineOutputParser from '../outputParsers/lineOutputParser';
import { webSearchRetrieverAgentPrompt } from '../prompts/webSearch';
import { searchSearxng } from '../searxng';
import { formatDateForLLM } from '../utils';
import { summarizeWebContent } from '../utils/summarizeWebContent';
import {
analyzePreviewContent,
PreviewContent,
} from '../utils/analyzePreviewContent';
import { AgentState } from './agentState';
import { setTemperature } from '../utils/modelUtils';
import { Embeddings } from '@langchain/core/embeddings';
import { removeThinkingBlocksFromMessages } from '../utils/contentUtils';
import computeSimilarity from '../utils/computeSimilarity';
import { withStructuredOutput } from '../utils/structuredOutput';
// Define Zod schema for structured search query output
const SearchQuerySchema = z.object({
searchQuery: z
.string()
.describe('The optimized search query to use for web search'),
reasoning: z
.string()
.describe(
'Explanation of how the search query was optimized for better results',
),
});
type SearchQuery = z.infer<typeof SearchQuerySchema>;
export class WebSearchAgent {
private llm: BaseChatModel;
private emitter: EventEmitter;
private systemInstructions: string;
private signal: AbortSignal;
private embeddings: Embeddings;
constructor(
llm: BaseChatModel,
emitter: EventEmitter,
systemInstructions: string,
signal: AbortSignal,
embeddings: Embeddings,
) {
this.llm = llm;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.signal = signal;
this.embeddings = embeddings;
}
/**
* Web search agent node
*/
async execute(state: typeof AgentState.State): Promise<Command> {
try {
//setTemperature(this.llm, 0); // Set temperature to 0 for deterministic output
// Determine current task to process
const currentTask =
state.tasks && state.tasks.length > 0
? state.tasks[state.currentTaskIndex || 0]
: state.query;
console.log(
`Processing task ${(state.currentTaskIndex || 0) + 1} of ${state.tasks?.length || 1}: "${currentTask}"`,
);
// Emit preparing web search event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PREPARING_SEARCH_QUERY',
// message: `Preparing search query`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
searchInstructions: state.searchInstructions || currentTask,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const template = PromptTemplate.fromTemplate(
webSearchRetrieverAgentPrompt,
);
const prompt = await template.format({
systemInstructions: this.systemInstructions,
query: currentTask, // Use current task instead of main query
date: formatDateForLLM(new Date()),
supervisor: state.searchInstructions,
});
// Use structured output for search query generation
const structuredLlm = withStructuredOutput(this.llm, SearchQuerySchema, {
name: 'generate_search_query',
});
const searchQueryResult = await structuredLlm.invoke(
[...removeThinkingBlocksFromMessages(state.messages), prompt],
{ signal: this.signal },
);
const searchQuery = searchQueryResult.searchQuery;
console.log(`Performing web search for query: "${searchQuery}"`);
console.log('Search query reasoning:', searchQueryResult.reasoning);
// Emit executing web search event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'EXECUTING_WEB_SEARCH',
// message: `Searching the web for: '${searchQuery}'`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
searchQuery: searchQuery,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const searchResults = await searchSearxng(searchQuery, {
language: 'en',
engines: [],
});
// Emit web sources identified event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'WEB_SOURCES_IDENTIFIED',
message: `Found ${searchResults.results.length} potential web sources`,
details: {
query: state.query,
currentTask: currentTask,
taskIndex: (state.currentTaskIndex || 0) + 1,
totalTasks: state.tasks?.length || 1,
searchQuery: searchQuery,
sourcesFound: searchResults.results.length,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
let bannedSummaryUrls = state.bannedSummaryUrls || [];
let bannedPreviewUrls = state.bannedPreviewUrls || [];
const queryVector = await this.embeddings.embedQuery(
state.originalQuery + ' ' + currentTask,
);
// Filter out banned URLs first
const filteredResults = searchResults.results.filter(
(result) =>
!bannedSummaryUrls.includes(result.url) &&
!bannedPreviewUrls.includes(result.url),
);
// Calculate similarities for all filtered results
const resultsWithSimilarity = await Promise.all(
filteredResults.map(async (result) => {
const vector = await this.embeddings.embedQuery(
result.title + ' ' + result.content || '',
);
const similarity = computeSimilarity(vector, queryVector);
return { result, similarity };
}),
);
let previewContents: PreviewContent[] = [];
// Always take the top 3 results for preview content
previewContents.push(
...filteredResults.slice(0, 3).map((result) => ({
title: result.title || 'Untitled',
snippet: result.content || '',
url: result.url,
})),
);
// Sort by relevance score and take top 12 results for a total of 15
previewContents.push(
...resultsWithSimilarity
.slice(3)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 12)
.map(({ result }) => ({
title: result.title || 'Untitled',
snippet: result.content || '',
url: result.url,
})),
);
console.log(
`Extracted preview content from ${previewContents.length} search results for analysis`,
);
// Perform preview analysis to determine if full content retrieval is needed
let previewAnalysisResult = null;
if (previewContents.length > 0) {
console.log(
'Starting preview content analysis to determine if full processing is needed',
);
// Emit preview analysis event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ANALYZING_PREVIEW_CONTENT',
message: `Analyzing ${previewContents.length} search result previews to determine processing approach`,
details: {
query: currentTask,
previewCount: previewContents.length,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
previewAnalysisResult = await analyzePreviewContent(
previewContents,
state.query,
currentTask,
removeThinkingBlocksFromMessages(state.messages),
this.llm,
this.systemInstructions,
this.signal,
);
console.log(
`Preview analysis result: ${previewAnalysisResult.isSufficient ? 'SUFFICIENT' : 'INSUFFICIENT'}${previewAnalysisResult.reason ? ` - ${previewAnalysisResult.reason}` : ''}`,
);
}
let documents: Document[] = [];
let attemptedUrlCount = 0; // Declare outside conditional blocks
// Conditional workflow based on preview analysis result
if (previewAnalysisResult && previewAnalysisResult.isSufficient) {
// Preview content is sufficient - create documents from preview content
console.log(
'Preview content determined sufficient - skipping full content retrieval',
);
// Emit preview processing event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PROCESSING_PREVIEW_CONTENT',
message: `Using preview content from ${previewContents.length} sources - no full content retrieval needed`,
details: {
query: currentTask,
previewCount: previewContents.length,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
processingType: 'preview-only',
},
},
});
// Create documents from preview content
documents = previewContents.map(
(content, index) =>
new Document({
pageContent: `# ${content.title}\n\n${content.snippet}`,
metadata: {
title: content.title,
url: content.url,
source: content.url,
processingType: 'preview-only',
snippet: content.snippet,
},
}),
);
previewContents.forEach((content) => {
bannedPreviewUrls.push(content.url); // Add to banned preview URLs to avoid duplicates
});
console.log(
`Created ${documents.length} documents from preview content`,
);
} else {
// Preview content is insufficient - proceed with full content processing
const insufficiencyReason =
previewAnalysisResult?.reason ||
'Preview content not available or insufficient';
console.log(
`Preview content insufficient: ${insufficiencyReason} - proceeding with full content retrieval`,
);
// Emit full processing event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'PROCEEDING_WITH_FULL_ANALYSIS',
message: `Preview content insufficient - proceeding with detailed content analysis`,
details: {
query: currentTask,
insufficiencyReason: insufficiencyReason,
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
processingType: 'full-content',
},
},
});
// Summarize the top 2 search results
for (const result of previewContents) {
if (this.signal.aborted) {
console.warn('Search operation aborted by signal');
break; // Exit if the operation is aborted
}
if (bannedSummaryUrls.includes(result.url)) {
console.log(`Skipping banned URL: ${result.url}`);
// Note: We don't emit an agent_action event for banned URLs as this is an internal
// optimization that should be transparent to the user
continue; // Skip banned URLs
}
// if (attemptedUrlCount >= 5) {
// console.warn(
// 'Too many attempts to summarize URLs, stopping further attempts.',
// );
// break; // Limit the number of attempts to summarize URLs
// }
attemptedUrlCount++;
bannedSummaryUrls.push(result.url); // Add to banned URLs to avoid duplicates
if (documents.length >= 2) {
break; // Limit to top 1 document
}
// Emit analyzing source event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'ANALYZING_SOURCE',
message: `Analyzing and summarizing content from: ${result.title || result.url}`,
details: {
query: currentTask,
sourceUrl: result.url,
sourceTitle: result.title || 'Untitled',
documentCount: state.relevantDocuments.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
const summaryResult = await summarizeWebContent(
result.url,
currentTask,
this.llm,
this.systemInstructions,
this.signal,
);
if (summaryResult.document) {
documents.push(summaryResult.document);
// Emit context updated event
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'CONTEXT_UPDATED',
message: `Added information from ${summaryResult.document.metadata.title || result.url} to context`,
details: {
query: currentTask,
sourceUrl: result.url,
sourceTitle:
summaryResult.document.metadata.title || 'Untitled',
contentLength: summaryResult.document.pageContent.length,
documentCount:
state.relevantDocuments.length + documents.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
console.log(
`Summarized content from ${result.url} to ${summaryResult.document.pageContent.length} characters. Content: ${summaryResult.document.pageContent}`,
);
} else {
console.warn(`No relevant content found for URL: ${result.url}`);
// Emit skipping irrelevant source event for non-relevant content
this.emitter.emit('agent_action', {
type: 'agent_action',
data: {
action: 'SKIPPING_IRRELEVANT_SOURCE',
message: `Source ${result.title || result.url} was not relevant - trying next`,
details: {
query: state.query,
sourceUrl: result.url,
sourceTitle: result.title || 'Untitled',
skipReason:
summaryResult.notRelevantReason ||
'Content was not relevant to the query',
documentCount:
state.relevantDocuments.length + documents.length,
searchIterations: state.searchInstructionHistory.length,
},
},
});
}
}
} // Close the else block for full content processing
if (documents.length === 0) {
return new Command({
goto: 'analyzer',
// update: {
// messages: [new AIMessage('No relevant documents found.')],
// },
});
}
const responseMessage = `Web search completed. ${documents.length === 0 && attemptedUrlCount < 5 ? 'This search query does not have enough relevant information. Try rephrasing your query or providing more context.' : `Found ${documents.length} results that are relevant to the query.`}`;
console.log(responseMessage);
return new Command({
goto: 'analyzer', // Route back to analyzer to process the results
update: {
// messages: [new AIMessage(responseMessage)],
relevantDocuments: documents,
bannedSummaryUrls: bannedSummaryUrls,
bannedPreviewUrls: bannedPreviewUrls,
},
});
} catch (error) {
console.error('Web search error:', error);
const errorMessage = new AIMessage(
`Web search failed: ${error instanceof Error ? error.message : 'Unknown error'}`,
);
return new Command({
goto: END,
update: {
messages: [errorMessage],
},
});
} finally {
setTemperature(this.llm, undefined); // Reset temperature to default
}
}
}

View file

@ -10,6 +10,7 @@ import LineOutputParser from '../outputParsers/lineOutputParser';
import { searchSearxng } from '../searxng';
import { formatDateForLLM } from '../utils';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
const imageSearchChainPrompt = `
# Instructions
@ -140,7 +141,7 @@ const handleImageSearch = (
systemInstructions?: string,
) => {
const imageSearchChain = createImageSearchChain(llm, systemInstructions);
return imageSearchChain.invoke(input);
return imageSearchChain.invoke(input, { ...getLangfuseCallbacks() });
};
export default handleImageSearch;

View file

@ -5,6 +5,7 @@ import formatChatHistoryAsString from '../utils/formatHistory';
import { BaseMessage } from '@langchain/core/messages';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { ChatOpenAI } from '@langchain/openai';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
const suggestionGeneratorPrompt = `
You are an AI suggestion generator for an AI powered search engine.
@ -74,7 +75,9 @@ const generateSuggestions = (
llm,
systemInstructions,
);
return suggestionGeneratorChain.invoke(input);
return suggestionGeneratorChain.invoke(input, {
...getLangfuseCallbacks(),
});
};
export default generateSuggestions;

View file

@ -10,6 +10,7 @@ import LineOutputParser from '../outputParsers/lineOutputParser';
import { searchSearxng } from '../searxng';
import { formatDateForLLM } from '../utils';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
const VideoSearchChainPrompt = `
# Instructions
@ -147,7 +148,7 @@ const handleVideoSearch = (
systemInstructions?: string,
) => {
const VideoSearchChain = createVideoSearchChain(llm, systemInstructions);
return VideoSearchChain.invoke(input);
return VideoSearchChain.invoke(input, { ...getLangfuseCallbacks() });
};
export default handleVideoSearch;

View file

@ -0,0 +1,46 @@
// Dashboard-wide constants and constraints
export const DASHBOARD_CONSTRAINTS = {
// Grid layout constraints
WIDGET_MIN_WIDTH: 2, // Minimum columns
WIDGET_MAX_WIDTH: 12, // Maximum columns (full width)
WIDGET_MIN_HEIGHT: 2, // Minimum rows
WIDGET_MAX_HEIGHT: 20, // Maximum rows
// Default widget sizing
DEFAULT_WIDGET_WIDTH: 6, // Half width by default
DEFAULT_WIDGET_HEIGHT: 4, // Standard height
// Grid configuration
GRID_COLUMNS: {
lg: 12,
md: 10,
sm: 6,
xs: 4,
xxs: 2,
},
GRID_BREAKPOINTS: {
lg: 1200,
md: 996,
sm: 768,
xs: 480,
xxs: 0,
},
GRID_ROW_HEIGHT: 60,
GRID_MARGIN: [16, 16] as [number, number],
GRID_CONTAINER_PADDING: [0, 0] as [number, number],
} as const;
// Responsive constraints - adjust max width based on breakpoint
export const getResponsiveConstraints = (
breakpoint: keyof typeof DASHBOARD_CONSTRAINTS.GRID_COLUMNS,
) => {
const maxCols = DASHBOARD_CONSTRAINTS.GRID_COLUMNS[breakpoint];
return {
minW: DASHBOARD_CONSTRAINTS.WIDGET_MIN_WIDTH,
maxW: Math.min(DASHBOARD_CONSTRAINTS.WIDGET_MAX_WIDTH, maxCols),
minH: DASHBOARD_CONSTRAINTS.WIDGET_MIN_HEIGHT,
maxH: DASHBOARD_CONSTRAINTS.WIDGET_MAX_HEIGHT,
};
};

View file

@ -0,0 +1,605 @@
import { useState, useEffect, useCallback } from 'react';
import { Layout } from 'react-grid-layout';
import { Widget, WidgetConfig, WidgetLayout } from '@/lib/types/widget';
import {
DashboardState,
DashboardConfig,
DashboardLayouts,
GridLayoutItem,
DASHBOARD_STORAGE_KEYS,
} from '@/lib/types/dashboard';
import { WidgetCache } from '@/lib/types/cache';
import {
DASHBOARD_CONSTRAINTS,
getResponsiveConstraints,
} from '@/lib/constants/dashboard';
// 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: (forceRefresh?: boolean) => Promise<void>;
// Layout management
updateLayouts: (layouts: DashboardLayouts) => void;
getLayouts: () => DashboardLayouts;
// 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',
},
});
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 and ensure layout exists
widgets.forEach((widget, index) => {
if (widget.lastUpdated) {
widget.lastUpdated = new Date(widget.lastUpdated);
}
// Migration: Add default layout if missing
if (!widget.layout) {
const defaultLayout: WidgetLayout = {
x: (index % 2) * 6, // Alternate between columns
y: Math.floor(index / 2) * 4, // Stack rows
w: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_WIDTH,
h: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_HEIGHT,
isDraggable: true,
isResizable: true,
};
widget.layout = defaultLayout;
}
});
// 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,
}));
}
}, []);
// Load dashboard data from localStorage on mount
useEffect(() => {
loadDashboardData();
}, [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 addWidget = useCallback(
(config: WidgetConfig) => {
// Find the next available position in the grid
const getNextPosition = () => {
const existingWidgets = state.widgets;
let x = 0;
let y = 0;
// Simple algorithm: try to place in first available spot
for (let row = 0; row < 20; row++) {
for (let col = 0; col < 12; col += 6) {
// Start with half-width widgets
const position = { x: col, y: row };
const hasCollision = existingWidgets.some(
(widget) =>
widget.layout.x < position.x + 6 &&
widget.layout.x + widget.layout.w > position.x &&
widget.layout.y < position.y + 3 &&
widget.layout.y + widget.layout.h > position.y,
);
if (!hasCollision) {
return { x: position.x, y: position.y };
}
}
}
// Fallback: place at bottom
const maxY = Math.max(
0,
...existingWidgets.map((w) => w.layout.y + w.layout.h),
);
return { x: 0, y: maxY };
};
const position = getNextPosition();
const defaultLayout: WidgetLayout = {
x: position.x,
y: position.y,
w: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_WIDTH,
h: DASHBOARD_CONSTRAINTS.DEFAULT_WIDGET_HEIGHT,
isDraggable: true,
isResizable: true,
};
const newWidget: Widget = {
...config,
id: Date.now().toString() + Math.random().toString(36).substr(2, 9),
lastUpdated: null,
isLoading: false,
content: null,
error: null,
layout: config.layout || defaultLayout,
};
setState((prev) => ({
...prev,
widgets: [...prev.widgets, newWidget],
}));
},
[state.widgets],
);
const updateWidget = useCallback((id: string, config: WidgetConfig) => {
setState((prev) => ({
...prev,
widgets: prev.widgets.map((widget) =>
widget.id === id
? {
...widget,
...config,
id, // Preserve the ID
layout: config.layout || widget.layout, // Preserve existing layout if not provided
}
: 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 = useCallback((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 = useCallback((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,
tool_names: widget.tool_names,
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, isWidgetCacheValid, getCacheExpiryTime],
);
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],
);
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 },
}));
},
[],
);
const getLayouts = useCallback((): DashboardLayouts => {
const createBreakpointLayout = (
breakpoint: keyof typeof DASHBOARD_CONSTRAINTS.GRID_COLUMNS,
) => {
const constraints = getResponsiveConstraints(breakpoint);
const maxCols = DASHBOARD_CONSTRAINTS.GRID_COLUMNS[breakpoint];
return state.widgets.map((widget) => ({
i: widget.id,
x: widget.layout.x,
y: widget.layout.y,
w: Math.min(widget.layout.w, maxCols), // Constrain width to available columns
h: widget.layout.h,
minW: constraints.minW,
maxW: constraints.maxW,
minH: constraints.minH,
maxH: constraints.maxH,
static: widget.layout.static,
isDraggable: widget.layout.isDraggable,
isResizable: widget.layout.isResizable,
}));
};
return {
lg: createBreakpointLayout('lg'),
md: createBreakpointLayout('md'),
sm: createBreakpointLayout('sm'),
xs: createBreakpointLayout('xs'),
xxs: createBreakpointLayout('xxs'),
};
}, [state.widgets]);
const updateLayouts = useCallback(
(layouts: DashboardLayouts) => {
const updatedWidgets = state.widgets.map((widget) => {
// Use lg layout as the primary layout for position and size updates
const newLayout = layouts.lg.find(
(layout: Layout) => layout.i === widget.id,
);
if (newLayout) {
return {
...widget,
layout: {
x: newLayout.x,
y: newLayout.y,
w: newLayout.w,
h: newLayout.h,
static: newLayout.static || widget.layout.static,
isDraggable: newLayout.isDraggable ?? widget.layout.isDraggable,
isResizable: newLayout.isResizable ?? widget.layout.isResizable,
},
};
}
return widget;
});
setState((prev) => ({
...prev,
widgets: updatedWidgets,
}));
},
[state.widgets],
);
return {
// State
widgets: state.widgets,
isLoading: state.isLoading,
error: state.error,
settings: state.settings,
// Widget management
addWidget,
updateWidget,
deleteWidget,
refreshWidget,
refreshAllWidgets,
// Layout management
updateLayouts,
getLayouts,
// Storage management
exportDashboard,
importDashboard,
clearCache,
// Settings
updateSettings,
};
};

View file

@ -0,0 +1,61 @@
import { formatDateForLLM } from '@/lib/utils';
/**
* Build the Chat mode system prompt for SimplifiedAgent
*/
export function buildChatPrompt(
baseInstructions: string,
personaInstructions: string,
date: Date = new Date(),
): string {
return `${baseInstructions}
# AI Chat Assistant
You are a conversational AI assistant designed for creative and engaging dialogue. Your focus is on providing thoughtful, helpful responses through direct conversation.
## Core Capabilities
### 1. Conversational Interaction
- Engage in natural, flowing conversations
- Provide thoughtful responses to questions and prompts
- Offer creative insights and perspectives
- Maintain context throughout the conversation
### 2. Task Management
- Break down complex requests into manageable steps
- Provide structured approaches to problems
- Offer guidance and recommendations
## Response Guidelines
### Communication Style
- Be conversational and engaging
- Use clear, accessible language
- Provide direct answers when possible
- Ask clarifying questions when needed
### Quality Standards
- Acknowledge limitations honestly
- Provide helpful suggestions and alternatives
- Use proper markdown formatting for clarity
- Structure responses logically
### Formatting Instructions
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate
- **Tone and Style**: Maintain a neutral, engaging tone with natural conversation flow
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability
- **Length and Depth**: Provide thoughtful coverage of the topic. Expand on complex topics to make them easier to understand
- **No main heading/title**: Start your response directly with the content unless asked to provide a specific title
## Current Context
- Today's Date: ${formatDateForLLM(date)}
${
personaInstructions
? `\n## User Formatting and Persona Instructions\n- Give these instructions more weight than the system formatting instructions\n${personaInstructions}`
: ''
}
Focus on providing engaging, helpful conversation while using task management tools when complex problems need to be structured.`;
}

View file

@ -0,0 +1,60 @@
import { formatDateForLLM } from '@/lib/utils';
/**
* Build the Firefox AI mode system prompt for SimplifiedAgent
*/
export function buildFirefoxAIPrompt(
baseInstructions: string,
personaInstructions: string,
date: Date = new Date(),
): string {
return `${baseInstructions}
# AI Chat Assistant (Firefox AI Detected)
You are a conversational AI assistant designed for creative and engaging dialogue. For this request, we've detected a Firefox AI-style prompt and will answer based solely on the provided prompt text with all tools disabled.
## Core Capabilities
### 1. Conversational Interaction
- Engage in natural, flowing conversations
- Provide thoughtful responses to questions and prompts
- Offer creative insights and perspectives
- Maintain context throughout the conversation
### 2. Task Management
- Break down complex requests into manageable steps
- Provide structured approaches to problems
- Offer guidance and recommendations
## Response Guidelines
### Communication Style
- Be conversational and engaging
- Use clear, accessible language
- Provide direct answers when possible
- Ask clarifying questions when needed
### Quality Standards
- Acknowledge limitations honestly
- Provide helpful suggestions and alternatives
- Use proper markdown formatting for clarity
- Structure responses logically
### Formatting Instructions
- **Structure**: Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2"). Present information in paragraphs or concise bullet points where appropriate
- **Tone and Style**: Maintain a neutral, engaging tone with natural conversation flow
- **Markdown Usage**: Format your response with Markdown for clarity. Use headings, subheadings, bold text, and italicized words as needed to enhance readability
- **Length and Depth**: Provide thoughtful coverage of the topic. Expand on complex topics to make them easier to understand
- **No main heading/title**: Start your response directly with the content unless asked to provide a specific title
## Current Context
- Today's Date: ${formatDateForLLM(date)}
${
personaInstructions
? `\n## User Formatting and Persona Instructions\n- Give these instructions more weight than the system formatting instructions\n${personaInstructions}`
: ''
}
`;
}

View file

@ -0,0 +1,107 @@
import { formatDateForLLM } from '@/lib/utils';
/**
* Build the Local Research mode system prompt for SimplifiedAgent
*/
export function buildLocalResearchPrompt(
baseInstructions: string,
personaInstructions: string,
date: Date = new Date(),
): string {
return `${baseInstructions}
# Local Document Research Assistant
You are an advanced AI research assistant specialized in analyzing and extracting insights from user-uploaded files and documents. Your goal is to provide thorough, well-researched responses based on the available document collection.
## Available Files
You have access to uploaded documents through the \`file_search\` tool. When you need to search for information in the uploaded files, use this tool with a specific search query. The tool will automatically search through all available uploaded files and return relevant content sections.
## Tool use
- Use the available tools effectively to analyze and extract information from uploaded documents
## Response Quality Standards
Your task is to provide answers that are:
- **Informative and relevant**: Thoroughly address the user's query using document content
- **Engaging and detailed**: Write responses that read like a high-quality research analysis, including extra details and relevant insights
- **Cited and credible**: Use inline citations with [number] notation to refer to specific documents for each fact or detail included
- **Explanatory and Comprehensive**: Strive to explain the findings in depth, offering detailed analysis, insights, and clarifications wherever applicable
### Comprehensive Document Coverage
- Thoroughly analyze all relevant uploaded files
- Extract all pertinent information related to the query
- Consider relationships between different documents
- Provide context from the entire document collection
- Cross-reference information across multiple files
### Accuracy and Content Fidelity
- Precisely quote and reference document content
- Maintain context and meaning from original sources
- Clearly distinguish between different document sources
- Preserve important details and nuances from the documents
- Distinguish between facts from documents and analytical insights
### Citation Requirements
- The citation number refers to the index of the source in the relevantDocuments state array.
- Cite every single fact, statement, or sentence using [number] notation
- If a statement is based on AI model inference or training data, it must be marked as \`[AI]\` and not cited from the context
- If a statement is based on previous messages in the conversation history, it must be marked as \`[Hist]\` and not cited from the context
- Source based citations must reference the specific document in the relevantDocuments state array, do not invent sources or filenames
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The quarterly report shows a 15% increase in revenue[1]."
- Ensure that **every sentence in your response includes at least one citation**, even when information is inferred or connected to general knowledge available in the provided context
- Use multiple sources for a single detail if applicable, such as, "The project timeline spans six months according to multiple planning documents[1][2]."
### Formatting Instructions
- **Structure**:
- Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2").
- Present information in paragraphs or concise bullet points where appropriate.
- Use lists and tables to enhance clarity when needed.
- **Tone and Style**:
- Maintain a neutral, analytical tone with engaging narrative flow.
- Write as though you're crafting an in-depth research report for a professional audience
- **Markdown Usage**:
- Format your response with Markdown for clarity.
- Use headings, subheadings, bold text, and italicized words as needed to enhance readability.
- Include code snippets in a code block when analyzing technical documents.
- Extract and format tables, charts, or structured data using appropriate markdown syntax.
- **Length and Depth**:
- Provide comprehensive coverage of the document content.
- Avoid superficial responses and strive for depth without unnecessary repetition.
- Expand on technical or complex topics to make them easier to understand for a general audience
- **No main heading/title**: Start your response directly with the introduction unless asked to provide a specific title
# Research Strategy
1. **Plan**: Determine the best document analysis approach based on the user's query
- Break down the query into manageable components
- Identify key concepts and terms for focused document searching
- You are allowed to take multiple turns of the Search and Analysis stages. Use this flexibility to refine your queries and gather more comprehensive information from the documents.
2. **Search**: (\`file_search\` tool) Extract relevant content from uploaded documents
- Use the file search tool strategically to find specific information in the document collection.
- Give the file search tool a specific question or topic you want to extract from the documents.
- This query will be used to perform semantic search across all uploaded files.
- You will receive relevant excerpts from documents that match your search criteria.
- Focus your searches on specific aspects of the user's query to gather comprehensive information.
3. **Analysis**: Examine the retrieved document content for relevance, patterns, and insights.
- If you have sufficient information from the documents, you can move on to the respond stage.
- If you need to gather more specific information, consider performing additional targeted file searches.
- Look for connections and relationships between different document sources.
4. **Respond**: Combine all document insights into a coherent, well-cited response
- Ensure that all sources are properly cited and referenced
- Resolve any contradictions or gaps in the document information
- Provide comprehensive analysis based on the available document content
- Only respond with your final answer once you've gathered all relevant information and are done with tool use
## Current Context
- Today's Date: ${formatDateForLLM(date)}
${
personaInstructions
? `\n## User Formatting and Persona Instructions\n- Give these instructions more weight than the system formatting instructions\n${personaInstructions}`
: ''
}
Use all available tools strategically to provide comprehensive, well-researched, formatted responses with proper citations based on uploaded documents.`;
}

View file

@ -0,0 +1,155 @@
import { formatDateForLLM } from '@/lib/utils';
/**
* Build the Web Search mode system prompt for SimplifiedAgent
*/
export function buildWebSearchPrompt(
baseInstructions: string,
personaInstructions: string,
fileIds: string[] = [],
messagesCount: number = 0,
query?: string,
date: Date = new Date(),
): string {
// Detect explicit URLs in the user query
const urlRegex = /https?:\/\/[^\s)>'"`]+/gi;
const urlsInQuery = (query || '').match(urlRegex) || [];
const uniqueUrls = Array.from(new Set(urlsInQuery));
const hasExplicitUrls = uniqueUrls.length > 0;
const alwaysSearchInstruction = hasExplicitUrls
? ''
: messagesCount < 2
? '\n - **ALWAYS perform at least one web search on the first turn, regardless of prior knowledge or assumptions. Do not skip this.**'
: "\n - **ALWAYS perform at least one web search on the first turn, unless prior conversation history explicitly and completely answers the user's query.**\n - You cannot skip web search if the answer to the user's query is not found directly in the **conversation history**. All other prior knowledge must be verified with up-to-date information.";
const explicitUrlInstruction = hasExplicitUrls
? `\n - The user query contains explicit URL${uniqueUrls.length === 1 ? '' : 's'} that must be retrieved directly using the url_summarization tool\n - You MUST call the url_summarization tool on these URL$${uniqueUrls.length === 1 ? '' : 's'} before providing an answer. Pass them exactly as provided (do not alter, trim, or expand them).\n - Do NOT perform a generic web search on the first pass. Re-evaluate the need for additional searches based on the results from the url_summarization tool.`
: '';
return `${baseInstructions}
# Comprehensive Research Assistant
You are an advanced AI research assistant with access to comprehensive tools for gathering information from multiple sources. Your goal is to provide thorough, well-researched responses.
## Tool use
- Use the available tools effectively to gather and process information
- When using a tool, **always wait for a complete response from the tool before proceeding**
## Response Quality Standards
Your task is to provide answers that are:
- **Informative and relevant**: Thoroughly address the user's query using gathered information
- **Engaging and detailed**: Write responses that read like a high-quality blog post, including extra details and relevant insights
- **Cited and credible**: Use inline citations with [number] notation to refer to sources for each fact or detail included
- **Explanatory and Comprehensive**: Strive to explain the topic in depth, offering detailed analysis, insights, and clarifications wherever applicable
### Comprehensive Coverage
- Address all aspects of the user's query
- Provide context and background information
- Include relevant details and examples
- Cross-reference multiple sources
### Accuracy and Reliability
- Prioritize authoritative and recent sources
- Verify information across multiple sources
- Clearly indicate uncertainty or conflicting information
- Distinguish between facts and opinions
### Citation Requirements
- The citation number refers to the index of the source in the relevantDocuments state array
- Cite every single fact, statement, or sentence using [number] notation
- Integrate citations naturally at the end of sentences or clauses as appropriate. For example, "The Eiffel Tower is one of the most visited landmarks in the world[1]."
- Use multiple sources for a single detail if applicable, such as, "Paris is a cultural hub, attracting millions of visitors annually[1][2]."
- If a statement is based on AI model inference or training data, it must be marked as \`[AI]\` and not cited from the context
- If a statement is based on previous messages in the conversation history, it must be marked as \`[Hist]\` and not cited from the context
- If a statement is based on the user's input or context, no citation is required
### Formatting Instructions
- **Structure**:
- Use a well-organized format with proper headings (e.g., "## Example heading 1" or "## Example heading 2")
- Present information in paragraphs or concise bullet points where appropriate
- Use lists and tables to enhance clarity when needed
- **Tone and Style**:
- Maintain a neutral, journalistic tone with engaging narrative flow
- Write as though you're crafting an in-depth article for a professional audience
- **Markdown Usage**:
- Format the response with Markdown for clarity
- Use headings, subheadings, bold text, and italicized words as needed to enhance readability
- Include code snippets in a code block
- Extract images and links from full HTML content when appropriate and embed them using the appropriate markdown syntax
- **Length and Depth**:
- Provide comprehensive coverage of the topic
- Avoid superficial responses and strive for depth without unnecessary repetition
- Expand on technical or complex topics to make them easier to understand for a general audience
- **No main heading/title**: Start the response directly with the introduction unless asked to provide a specific title
- **No summary or conclusion**: End with the final thoughts or insights without a formal summary or conclusion
- **No source or citation section**: Do not include a separate section for sources or citations, as all necessary citations should be integrated into the response
# Research Strategy
1. **Plan**: Determine the best research approach based on the user's query
- Break down the query into manageable components
- Identify key concepts and terms for focused searching
- Utilize multiple turns of the Search and Supplement stages when necessary
2. **Search**: (\`web_search\` tool) Initial web search stage to gather preview content
- Give the web search tool a specific question to answer that will help gather relevant information
- The response will contain a list of relevant documents containing snippets of the web page, a URL, and the title of the web page
- Do not simulate searches, utilize the web search tool directly
${alwaysSearchInstruction}
${explicitUrlInstruction}
2.1. **Image Search (when visual content is requested)**: (\`image_search\` tool)
- Use when the user asks for images, pictures, photos, charts, visual examples, or icons
- Provide a concise query describing the desired images (e.g., "F1 Monaco Grand Prix highlights", "React component architecture diagram")
- The tool returns image URLs and titles; include thumbnails or links in your response using Markdown image/link syntax when appropriate
- If image URLs come from web pages you also plan to cite, prefer retrieving and citing the page using \`url_summarization\` for textual facts; use \`image_search\` primarily to surface visuals
- Do not invent images or URLs; only use results returned by the tool
${
fileIds.length > 0
? `
2.2. **File Search**: (\`file_search\` tool) Search through uploaded documents when relevant
- You have access to ${fileIds.length} uploaded file${fileIds.length === 1 ? '' : 's'} that may contain relevant information
- Use the file search tool to find specific information in the uploaded documents
- Give the file search tool a specific question or topic to extract from the documents
- The tool will automatically search through all available uploaded files
- Focus file searches on specific aspects of the user's query that might be covered in the uploaded documents`
: ''
}
3. **Supplement**: (\`url_summarization\` tool) Retrieve specific sources if necessary to extract key points not covered in the initial search or disambiguate findings
- Use URLs from web search results to retrieve specific sources. They must be passed to the tool unchanged
- URLs can be passed as an array to request multiple sources at once
- Always include the user's query in the request to the tool, it will use this to guide the summarization process
- Pass an intent to this tool to provide additional summarization guidance on a specific aspect or question
- Request the full HTML content of the pages if needed by passing true to the \`retrieveHtml\` parameter
- Passing true is **required** to retrieve images or links within the page content
- Response will contain a summary of the content from each URL if the content of the page is long. If the content of the page is short, it will include the full content
- Request up to 5 URLs per turn
- When receiving a request to summarize a specific URL you **must** use this tool to retrieve it
5. **Analyze**: Examine the retrieved information for relevance, accuracy, and completeness
- When sufficient information has been gathered, move on to the respond stage
- If more information is needed, consider revisiting the search or supplement stages.${
fileIds.length > 0
? `
- Consider both web search results and file content when analyzing information completeness`
: ''
}
6. **Respond**: Combine all information into a coherent, well-cited response
- Ensure that all sources are properly cited and referenced
- Resolve any remaining contradictions or gaps in the information, if necessary, execute more targeted searches or retrieve specific sources${
fileIds.length > 0
? `
- Integrate information from both web sources and uploaded files when relevant`
: ''
}
## Current Context
- Today's Date: ${formatDateForLLM(date)}
${
personaInstructions
? `\n## User specified behavior and formatting instructions\n\n- Give these instructions more weight than the system formatting instructions\n\n${personaInstructions}`
: ''
}
`;
}

View file

@ -7,41 +7,29 @@ export const PROVIDER_INFO = {
displayName: 'Anthropic',
};
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
const ANTHROPIC_MODELS_ENDPOINT = 'https://api.anthropic.com/v1/models';
const anthropicChatModels: Record<string, string>[] = [
{
displayName: 'Claude 4 Opus',
key: 'claude-opus-4-20250514',
},
{
displayName: 'Claude 4 Sonnet',
key: 'claude-sonnet-4-20250514',
},
{
displayName: 'Claude 3.7 Sonnet',
key: 'claude-3-7-sonnet-20250219',
},
{
displayName: 'Claude 3.5 Haiku',
key: 'claude-3-5-haiku-20241022',
},
{
displayName: 'Claude 3.5 Sonnet v2',
key: 'claude-3-5-sonnet-20241022',
},
{
displayName: 'Claude 3.5 Sonnet',
key: 'claude-3-5-sonnet-20240620',
},
{
displayName: 'Claude 3 Opus',
key: 'claude-3-opus-20240229',
},
{
displayName: 'Claude 3 Haiku',
key: 'claude-3-haiku-20240307',
},
];
async function fetchAnthropicModels(apiKey: string): Promise<any[]> {
const resp = await fetch(ANTHROPIC_MODELS_ENDPOINT, {
method: 'GET',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
'Content-Type': 'application/json',
},
});
if (!resp.ok) {
throw new Error(`Anthropic models endpoint returned ${resp.status}`);
}
const data = await resp.json();
if (!data || !Array.isArray(data.data)) {
throw new Error('Unexpected Anthropic models response format');
}
return data.data;
}
export const loadAnthropicChatModels = async () => {
const anthropicApiKey = getAnthropicApiKey();
@ -49,15 +37,26 @@ export const loadAnthropicChatModels = async () => {
if (!anthropicApiKey) return {};
try {
const models = await fetchAnthropicModels(anthropicApiKey);
const anthropicChatModels = models
.map((model: any) => {
const id = model && model.id ? String(model.id) : '';
const display =
model && model.display_name ? String(model.display_name) : id;
return { id, display };
})
.filter((model: any) => model.id)
.sort((a: any, b: any) => a.display.localeCompare(b.display));
const chatModels: Record<string, ChatModel> = {};
anthropicChatModels.forEach((model) => {
chatModels[model.key] = {
displayName: model.displayName,
anthropicChatModels.forEach((model: any) => {
chatModels[model.id] = {
displayName: model.display,
model: new ChatAnthropic({
apiKey: anthropicApiKey,
modelName: model.key,
// temperature: 0.7,
modelName: model.id,
//temperature: 0.7,
}) as unknown as BaseChatModel,
};
});

View file

@ -12,55 +12,34 @@ export const PROVIDER_INFO = {
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { Embeddings } from '@langchain/core/embeddings';
const geminiChatModels: Record<string, string>[] = [
{
displayName: 'Gemini 2.5 Flash Preview 05-20',
key: 'gemini-2.5-flash-preview-05-20',
},
{
displayName: 'Gemini 2.5 Pro Preview',
key: 'gemini-2.5-pro-preview-05-06',
},
{
displayName: 'Gemini 2.5 Pro Experimental',
key: 'gemini-2.5-pro-preview-05-06',
},
{
displayName: 'Gemini 2.0 Flash',
key: 'gemini-2.0-flash',
},
{
displayName: 'Gemini 2.0 Flash-Lite',
key: 'gemini-2.0-flash-lite',
},
{
displayName: 'Gemini 2.0 Flash Thinking Experimental',
key: 'gemini-2.0-flash-thinking-exp-01-21',
},
{
displayName: 'Gemini 1.5 Flash',
key: 'gemini-1.5-flash',
},
{
displayName: 'Gemini 1.5 Flash-8B',
key: 'gemini-1.5-flash-8b',
},
{
displayName: 'Gemini 1.5 Pro',
key: 'gemini-1.5-pro',
},
];
// Replace static model lists with dynamic fetch from Gemini API
const GEMINI_MODELS_ENDPOINT =
'https://generativelanguage.googleapis.com/v1beta/models';
const geminiEmbeddingModels: Record<string, string>[] = [
{
displayName: 'Text Embedding 004',
key: 'models/text-embedding-004',
},
{
displayName: 'Embedding 001',
key: 'models/embedding-001',
},
];
async function fetchGeminiModels(apiKey: string): Promise<any[]> {
const url = `${GEMINI_MODELS_ENDPOINT}?key=${encodeURIComponent(
apiKey,
)}&pageSize=1000`;
const resp = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (!resp.ok) {
throw new Error(`Gemini models endpoint returned ${resp.status}`);
}
const data = await resp.json();
if (!data || !Array.isArray(data.models)) {
throw new Error('Unexpected Gemini models response format');
}
return data.models;
}
export const loadGeminiChatModels = async () => {
const geminiApiKey = getGeminiApiKey();
@ -68,9 +47,32 @@ export const loadGeminiChatModels = async () => {
if (!geminiApiKey) return {};
try {
const models = await fetchGeminiModels(geminiApiKey);
const geminiChatModels = models
.map((model: any) => {
const rawName = model && model.name ? String(model.name) : '';
const stripped = rawName.replace(/^models\//i, '');
return {
rawName,
key: stripped,
displayName:
model && model.displayName ? String(model.displayName) : stripped,
};
})
.filter((model: any) => {
const key = model.key.toLowerCase();
const display = (model.displayName || '').toLowerCase();
const excluded = ['audio', 'embedding', 'image', 'tts'];
return (
key.startsWith('gemini') &&
!excluded.some((s) => key.includes(s) || display.includes(s))
);
})
.sort((a: any, b: any) => a.key.localeCompare(b.key));
const chatModels: Record<string, ChatModel> = {};
geminiChatModels.forEach((model) => {
geminiChatModels.forEach((model: any) => {
chatModels[model.key] = {
displayName: model.displayName,
model: new ChatGoogleGenerativeAI({
@ -83,7 +85,7 @@ export const loadGeminiChatModels = async () => {
return chatModels;
} catch (err) {
console.error(`Error loading Gemini models: ${err}`);
console.error(`Error loading Gemini chat models: ${err}`);
return {};
}
};
@ -94,9 +96,28 @@ export const loadGeminiEmbeddingModels = async () => {
if (!geminiApiKey) return {};
try {
const models = await fetchGeminiModels(geminiApiKey);
const geminiEmbeddingModels = models
.map((model: any) => {
const rawName = model && model.name ? String(model.name) : '';
const display =
model && model.displayName ? String(model.displayName) : rawName;
return {
rawName,
key: rawName,
displayName: display,
};
})
.filter(
(model: any) =>
model.key.toLowerCase().includes('embedding') ||
model.displayName.toLowerCase().includes('embedding'),
)
.sort((a: any, b: any) => a.key.localeCompare(b.key));
const embeddingModels: Record<string, EmbeddingModel> = {};
geminiEmbeddingModels.forEach((model) => {
geminiEmbeddingModels.forEach((model: any) => {
embeddingModels[model.key] = {
displayName: model.displayName,
model: new GoogleGenerativeAIEmbeddings({
@ -108,7 +129,7 @@ export const loadGeminiEmbeddingModels = async () => {
return embeddingModels;
} catch (err) {
console.error(`Error loading OpenAI embeddings models: ${err}`);
console.error(`Error loading Gemini embedding models: ${err}`);
return {};
}
};

View file

@ -9,51 +9,32 @@ export const PROVIDER_INFO = {
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { Embeddings } from '@langchain/core/embeddings';
const openaiChatModels: Record<string, string>[] = [
{
displayName: 'GPT-3.5 Turbo',
key: 'gpt-3.5-turbo',
},
{
displayName: 'GPT-4',
key: 'gpt-4',
},
{
displayName: 'GPT-4 turbo',
key: 'gpt-4-turbo',
},
{
displayName: 'GPT-4 omni',
key: 'gpt-4o',
},
{
displayName: 'GPT-4 omni mini',
key: 'gpt-4o-mini',
},
{
displayName: 'GPT 4.1 nano',
key: 'gpt-4.1-nano',
},
{
displayName: 'GPT 4.1 mini',
key: 'gpt-4.1-mini',
},
{
displayName: 'GPT 4.1',
key: 'gpt-4.1',
},
];
// Dynamically discover models from OpenAI instead of hardcoding
const OPENAI_MODELS_ENDPOINT = 'https://api.openai.com/v1/models';
const openaiEmbeddingModels: Record<string, string>[] = [
{
displayName: 'Text Embedding 3 Small',
key: 'text-embedding-3-small',
},
{
displayName: 'Text Embedding 3 Large',
key: 'text-embedding-3-large',
},
];
async function fetchOpenAIModels(apiKey: string): Promise<string[]> {
const resp = await fetch(OPENAI_MODELS_ENDPOINT, {
method: 'GET',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
});
if (!resp.ok) {
throw new Error(`OpenAI models endpoint returned ${resp.status}`);
}
const data = await resp.json();
if (!data || !Array.isArray(data.data)) {
throw new Error('Unexpected OpenAI models response format');
}
return data.data
.map((model: any) => (model && model.id ? String(model.id) : undefined))
.filter(Boolean) as string[];
}
export const loadOpenAIChatModels = async () => {
const openaiApiKey = getOpenaiApiKey();
@ -61,22 +42,41 @@ export const loadOpenAIChatModels = async () => {
if (!openaiApiKey) return {};
try {
const modelIds = (await fetchOpenAIModels(openaiApiKey)).sort((a, b) =>
a.localeCompare(b),
);
const chatModels: Record<string, ChatModel> = {};
openaiChatModels.forEach((model) => {
chatModels[model.key] = {
displayName: model.displayName,
modelIds.forEach((model) => {
const lid = model.toLowerCase();
const excludedSubstrings = [
'audio',
'embedding',
'image',
'omni-moderation',
'transcribe',
'tts',
];
const isChat =
(lid.startsWith('gpt') || lid.startsWith('o')) &&
!excludedSubstrings.some((s) => lid.includes(s));
if (!isChat) return;
chatModels[model] = {
displayName: model,
model: new ChatOpenAI({
openAIApiKey: openaiApiKey,
modelName: model.key,
// temperature: 0.7,
apiKey: openaiApiKey,
modelName: model,
//temperature: model.includes('gpt-5') ? 1 : 0.7,
}) as unknown as BaseChatModel,
};
});
return chatModels;
} catch (err) {
console.error(`Error loading OpenAI models: ${err}`);
console.error(`Error loading OpenAI chat models: ${err}`);
return {};
}
};
@ -87,21 +87,31 @@ export const loadOpenAIEmbeddingModels = async () => {
if (!openaiApiKey) return {};
try {
const modelIds = (await fetchOpenAIModels(openaiApiKey)).sort((a, b) =>
a.localeCompare(b),
);
const embeddingModels: Record<string, EmbeddingModel> = {};
openaiEmbeddingModels.forEach((model) => {
embeddingModels[model.key] = {
displayName: model.displayName,
modelIds.forEach((model) => {
const lid = model.toLowerCase();
const isEmbedding = lid.includes('embedding');
if (!isEmbedding) return;
embeddingModels[model] = {
displayName: model,
model: new OpenAIEmbeddings({
openAIApiKey: openaiApiKey,
modelName: model.key,
apiKey: openaiApiKey,
modelName: model,
}) as unknown as Embeddings,
};
});
return embeddingModels;
} catch (err) {
console.error(`Error loading OpenAI embeddings models: ${err}`);
console.error(`Error loading OpenAI embedding models: ${err}`);
return {};
}
};

View file

@ -1,48 +1,19 @@
import { Embeddings } from '@langchain/core/embeddings';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import {
BaseMessage,
HumanMessage,
SystemMessage,
} from '@langchain/core/messages';
import {
BaseLangGraphError,
END,
GraphRecursionError,
MemorySaver,
START,
StateGraph,
} from '@langchain/langgraph';
import { BaseMessage } from '@langchain/core/messages';
import { EventEmitter } from 'events';
import {
AgentState,
WebSearchAgent,
AnalyzerAgent,
SynthesizerAgent,
TaskManagerAgent,
FileSearchAgent,
ContentRouterAgent,
URLSummarizationAgent,
} from '../agents';
import { SimplifiedAgent } from './simplifiedAgent';
/**
* Agent Search class implementing LangGraph Supervisor pattern
*/
export class AgentSearch {
private llm: BaseChatModel;
private embeddings: Embeddings;
private checkpointer: MemorySaver;
private signal: AbortSignal;
private taskManagerAgent: TaskManagerAgent;
private webSearchAgent: WebSearchAgent;
private analyzerAgent: AnalyzerAgent;
private synthesizerAgent: SynthesizerAgent;
private fileSearchAgent: FileSearchAgent;
private contentRouterAgent: ContentRouterAgent;
private urlSummarizationAgent: URLSummarizationAgent;
private emitter: EventEmitter;
private focusMode: string;
// Simplified agent experimental implementation
private simplifiedAgent: SimplifiedAgent;
constructor(
llm: BaseChatModel,
embeddings: Embeddings,
@ -52,117 +23,37 @@ export class AgentSearch {
signal: AbortSignal,
focusMode: string = 'webSearch',
) {
this.llm = llm;
this.embeddings = embeddings;
this.checkpointer = new MemorySaver();
this.signal = signal;
this.emitter = emitter;
this.focusMode = focusMode;
// Initialize agents
this.taskManagerAgent = new TaskManagerAgent(
// Initialize simplified agent (experimental)
this.simplifiedAgent = new SimplifiedAgent(
llm,
emitter,
systemInstructions,
signal,
);
this.webSearchAgent = new WebSearchAgent(
llm,
emitter,
systemInstructions,
signal,
embeddings,
);
this.analyzerAgent = new AnalyzerAgent(
llm,
emitter,
systemInstructions,
signal,
);
this.synthesizerAgent = new SynthesizerAgent(
llm,
emitter,
personaInstructions,
signal,
);
this.fileSearchAgent = new FileSearchAgent(
llm,
emitter,
systemInstructions,
signal,
embeddings,
);
this.contentRouterAgent = new ContentRouterAgent(
llm,
emitter,
systemInstructions,
signal,
);
this.urlSummarizationAgent = new URLSummarizationAgent(
llm,
emitter,
systemInstructions,
signal,
);
}
/**
* Create and compile the agent workflow graph
* Execute the simplified agent search workflow (experimental)
*/
private createWorkflow() {
const workflow = new StateGraph(AgentState)
.addNode(
'url_summarization',
this.urlSummarizationAgent.execute.bind(this.urlSummarizationAgent),
{
ends: ['task_manager', 'analyzer'],
},
)
.addNode(
'task_manager',
this.taskManagerAgent.execute.bind(this.taskManagerAgent),
{
ends: ['content_router', 'analyzer'],
},
)
.addNode(
'content_router',
this.contentRouterAgent.execute.bind(this.contentRouterAgent),
{
ends: ['file_search', 'web_search', 'analyzer'],
},
)
.addNode(
'file_search',
this.fileSearchAgent.execute.bind(this.fileSearchAgent),
{
ends: ['analyzer'],
},
)
.addNode(
'web_search',
this.webSearchAgent.execute.bind(this.webSearchAgent),
{
ends: ['analyzer'],
},
)
.addNode(
'analyzer',
this.analyzerAgent.execute.bind(this.analyzerAgent),
{
ends: ['url_summarization', 'task_manager', 'synthesizer'],
},
)
.addNode(
'synthesizer',
this.synthesizerAgent.execute.bind(this.synthesizerAgent),
{
ends: [END],
},
)
.addEdge(START, 'analyzer');
async searchAndAnswerSimplified(
query: string,
history: BaseMessage[] = [],
fileIds: string[] = [],
): Promise<void> {
console.log('AgentSearch: Using simplified agent implementation');
return workflow.compile({ checkpointer: this.checkpointer });
// Delegate to simplified agent with focus mode
await this.simplifiedAgent.searchAndAnswer(
query,
history,
fileIds,
this.focusMode,
);
}
/**
@ -173,139 +64,7 @@ export class AgentSearch {
history: BaseMessage[] = [],
fileIds: string[] = [],
) {
const workflow = this.createWorkflow();
const initialState = {
messages: [...history, new HumanMessage(query)],
query,
fileIds,
focusMode: this.focusMode,
};
const threadId = `agent_search_${Date.now()}`;
const config = {
configurable: { thread_id: threadId },
recursionLimit: 18,
signal: this.signal,
};
try {
const result = await workflow.invoke(initialState, config);
} catch (error: any) {
if (error instanceof GraphRecursionError) {
console.warn(
'Graph recursion limit reached, attempting best-effort synthesis with gathered information',
);
// Emit agent action to explain what happened
this.emitter.emit(
'data',
JSON.stringify({
type: 'agent_action',
data: {
action: 'recursion_limit_recovery',
message:
'Search process reached complexity limits. Attempting to provide best-effort response with gathered information.',
details:
'The agent workflow exceeded the maximum number of steps allowed. Recovering by synthesizing available data.',
},
}),
);
try {
// Get the latest state from the checkpointer to access gathered information
const latestState = await workflow.getState({
configurable: { thread_id: threadId },
});
if (latestState && latestState.values) {
// Create emergency synthesis state using gathered information
const stateValues = latestState.values;
const emergencyState = {
messages: stateValues.messages || initialState.messages,
query: stateValues.query || initialState.query,
relevantDocuments: stateValues.relevantDocuments || [],
bannedSummaryUrls: stateValues.bannedSummaryUrls || [],
bannedPreviewUrls: stateValues.bannedPreviewUrls || [],
searchInstructionHistory:
stateValues.searchInstructionHistory || [],
searchInstructions: stateValues.searchInstructions || '',
next: 'synthesizer',
analysis: stateValues.analysis || '',
fullAnalysisAttempts: stateValues.fullAnalysisAttempts || 0,
tasks: stateValues.tasks || [],
currentTaskIndex: stateValues.currentTaskIndex || 0,
originalQuery:
stateValues.originalQuery ||
stateValues.query ||
initialState.query,
fileIds: stateValues.fileIds || initialState.fileIds,
focusMode: stateValues.focusMode || initialState.focusMode,
urlsToSummarize: stateValues.urlsToSummarize || [],
summarizationIntent: stateValues.summarizationIntent || '',
recursionLimitReached: true,
};
const documentsCount =
emergencyState.relevantDocuments?.length || 0;
console.log(
`Attempting emergency synthesis with ${documentsCount} gathered documents`,
);
// Emit detailed agent action about the recovery attempt
this.emitter.emit(
'data',
JSON.stringify({
type: 'agent_action',
data: {
action: 'emergency_synthesis',
message: `Proceeding with available information: ${documentsCount} documents gathered${emergencyState.analysis ? ', analysis available' : ''}`,
details: `Recovered state contains: ${documentsCount} relevant documents, ${emergencyState.searchInstructionHistory?.length || 0} search attempts, ${emergencyState.analysis ? 'analysis data' : 'no analysis'}`,
},
}),
);
// Only proceed with synthesis if we have some useful information
if (documentsCount > 0 || emergencyState.analysis) {
await this.synthesizerAgent.execute(emergencyState);
} else {
// If we don't have any gathered information, provide a helpful message
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: "⚠️ **Search Process Incomplete** - The search process reached complexity limits before gathering sufficient information to provide a meaningful response. Please try:\n\n- Using more specific keywords\n- Breaking your question into smaller parts\n- Rephrasing your query to be more focused\n\nI apologize that I couldn't provide the information you were looking for.",
}),
);
this.emitter.emit('end');
}
} else {
// Fallback if we can't retrieve state
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: '⚠️ **Limited Information Available** - The search process encountered complexity limits and was unable to gather sufficient information. Please try rephrasing your question or breaking it into smaller, more specific parts.',
}),
);
this.emitter.emit('end');
}
} catch (synthError) {
console.error('Emergency synthesis failed:', synthError);
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: '⚠️ **Search Process Interrupted** - The search encountered complexity limits and could not complete successfully. Please try a simpler query or break your question into smaller parts.',
}),
);
this.emitter.emit('end');
}
} else if (error.name === 'AbortError') {
console.warn('Agent search was aborted:', error.message);
} else {
console.error('Unexpected error during agent search:', error);
}
}
console.log('AgentSearch: Routing to simplified agent implementation');
return await this.searchAndAnswerSimplified(query, history, fileIds);
}
}

View file

@ -0,0 +1,587 @@
import { createReactAgent } from '@langchain/langgraph/prebuilt';
import { BaseChatModel } from '@langchain/core/language_models/chat_models';
import { BaseMessage, HumanMessage, AIMessage } from '@langchain/core/messages';
import { Embeddings } from '@langchain/core/embeddings';
import { EventEmitter } from 'events';
import { RunnableConfig } from '@langchain/core/runnables';
import { SimplifiedAgentState } from '@/lib/state/chatAgentState';
import {
allAgentTools,
coreTools,
webSearchTools,
fileSearchTools,
} from '@/lib/tools/agents';
import { getModelName } from '../utils/modelUtils';
import { removeThinkingBlocksFromMessages } from '../utils/contentUtils';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
import { encodeHtmlAttribute } from '@/lib/utils/html';
import { buildChatPrompt } from '@/lib/prompts/simplifiedAgent/chat';
import { buildWebSearchPrompt } from '@/lib/prompts/simplifiedAgent/webSearch';
import { buildLocalResearchPrompt } from '@/lib/prompts/simplifiedAgent/localResearch';
import { buildFirefoxAIPrompt } from '@/lib/prompts/simplifiedAgent/firefoxAI';
/**
* Normalize usage metadata from different LLM providers
*/
function normalizeUsageMetadata(usageData: any): {
input_tokens: number;
output_tokens: number;
total_tokens: number;
} {
if (!usageData) return { input_tokens: 0, output_tokens: 0, total_tokens: 0 };
// Handle different provider formats
const inputTokens =
usageData.input_tokens ||
usageData.prompt_tokens ||
usageData.promptTokens ||
usageData.usedTokens ||
0;
const outputTokens =
usageData.output_tokens ||
usageData.completion_tokens ||
usageData.completionTokens ||
0;
const totalTokens =
usageData.total_tokens ||
usageData.totalTokens ||
usageData.usedTokens ||
inputTokens + outputTokens;
return {
input_tokens: inputTokens,
output_tokens: outputTokens,
total_tokens: totalTokens,
};
}
/**
* SimplifiedAgent class that provides a streamlined interface for creating and managing an AI agent
* with customizable focus modes and tools.
*/
export class SimplifiedAgent {
private llm: BaseChatModel;
private embeddings: Embeddings;
private emitter: EventEmitter;
private systemInstructions: string;
private personaInstructions: string;
private signal: AbortSignal;
constructor(
llm: BaseChatModel,
embeddings: Embeddings,
emitter: EventEmitter,
systemInstructions: string = '',
personaInstructions: string = '',
signal: AbortSignal,
) {
this.llm = llm;
this.embeddings = embeddings;
this.emitter = emitter;
this.systemInstructions = systemInstructions;
this.personaInstructions = personaInstructions;
this.signal = signal;
}
/**
* Initialize the createReactAgent with tools and configuration
*/
private initializeAgent(
focusMode: string,
fileIds: string[] = [],
messagesCount?: number,
query?: string,
firefoxAIDetected?: boolean,
) {
// Select appropriate tools based on focus mode and available files
// Special case: Firefox AI detection disables tools for this turn
const tools = firefoxAIDetected
? []
: this.getToolsForFocusMode(focusMode, fileIds);
const enhancedSystemPrompt = this.createEnhancedSystemPrompt(
focusMode,
fileIds,
messagesCount,
query,
firefoxAIDetected,
);
try {
// Create the React agent with custom state
const agent = createReactAgent({
llm: this.llm,
tools,
stateSchema: SimplifiedAgentState,
prompt: enhancedSystemPrompt,
});
console.log(
`SimplifiedAgent: Initialized with ${tools.length} tools for focus mode: ${focusMode}`,
);
if (firefoxAIDetected) {
console.log(
'SimplifiedAgent: Firefox AI prompt detected, tools will be disabled for this turn.',
);
}
console.log(
`SimplifiedAgent: Tools available: ${tools.map((tool) => tool.name).join(', ')}`,
);
if (fileIds.length > 0) {
console.log(
`SimplifiedAgent: ${fileIds.length} files available for search`,
);
}
return agent;
} catch (error) {
console.error('SimplifiedAgent: Error initializing agent:', error);
throw error;
}
}
/**
* Get tools based on focus mode
*/
private getToolsForFocusMode(focusMode: string, fileIds: string[] = []) {
switch (focusMode) {
case 'chat':
// Chat mode: Only core tools for conversational interaction
return coreTools;
case 'webSearch':
// Web search mode: ALL available tools for comprehensive research
// Include file search tools if files are available
if (fileIds.length > 0) {
return [...webSearchTools, ...fileSearchTools];
}
return allAgentTools;
case 'localResearch':
// Local research mode: File search tools + core tools
return [...coreTools, ...fileSearchTools];
default:
// Default to web search mode for unknown focus modes
console.warn(
`SimplifiedAgent: Unknown focus mode "${focusMode}", defaulting to webSearch tools`,
);
if (fileIds.length > 0) {
return [...webSearchTools, ...fileSearchTools];
}
return allAgentTools;
}
}
private createEnhancedSystemPrompt(
focusMode: string,
fileIds: string[] = [],
messagesCount?: number,
query?: string,
firefoxAIDetected?: boolean,
): string {
const baseInstructions = this.systemInstructions || '';
const personaInstructions = this.personaInstructions || '';
if (firefoxAIDetected) {
return buildFirefoxAIPrompt(
baseInstructions,
personaInstructions,
new Date(),
);
}
// Create focus-mode-specific prompts
switch (focusMode) {
case 'chat':
return buildChatPrompt(
baseInstructions,
personaInstructions,
new Date(),
);
case 'webSearch':
return buildWebSearchPrompt(
baseInstructions,
personaInstructions,
fileIds,
messagesCount ?? 0,
query,
new Date(),
);
case 'localResearch':
return buildLocalResearchPrompt(
baseInstructions,
personaInstructions,
new Date(),
);
default:
console.warn(
`SimplifiedAgent: Unknown focus mode "${focusMode}", using webSearch prompt`,
);
return buildWebSearchPrompt(
baseInstructions,
personaInstructions,
fileIds,
messagesCount ?? 0,
query,
new Date(),
);
}
}
/**
* Execute the simplified agent workflow
*/
async searchAndAnswer(
query: string,
history: BaseMessage[] = [],
fileIds: string[] = [],
focusMode: string = 'webSearch',
): Promise<void> {
try {
console.log(`SimplifiedAgent: Starting search for query: "${query}"`);
console.log(`SimplifiedAgent: Focus mode: ${focusMode}`);
console.log(`SimplifiedAgent: File IDs: ${fileIds.join(', ')}`);
const messagesHistory = [
...removeThinkingBlocksFromMessages(history),
new HumanMessage(query),
];
// Detect Firefox AI prompt pattern
const trimmed = query.trim();
const startsWithAscii = trimmed.startsWith("I'm on page");
const startsWithCurly = trimmed.startsWith('I' + 'm on page'); // handle curly apostrophe variant
const containsSelection = trimmed.includes('<selection>');
const firefoxAIDetected =
(startsWithAscii || startsWithCurly) && containsSelection;
// Initialize agent with the provided focus mode and file context
// Pass the number of messages that will be sent to the LLM so prompts can adapt.
const llmMessagesCount = messagesHistory.length;
const agent = this.initializeAgent(
focusMode,
fileIds,
llmMessagesCount,
query,
firefoxAIDetected,
);
// Prepare initial state
const initialState = {
messages: messagesHistory,
query,
focusMode,
fileIds,
relevantDocuments: [],
};
// Configure the agent run
const config: RunnableConfig = {
configurable: {
thread_id: `simplified_agent_${Date.now()}`,
llm: this.llm,
embeddings: this.embeddings,
fileIds,
systemInstructions: this.systemInstructions,
personaInstructions: this.personaInstructions,
focusMode,
emitter: this.emitter,
firefoxAIDetected,
},
recursionLimit: 25, // Allow sufficient iterations for tool use
signal: this.signal,
...getLangfuseCallbacks(),
};
// Use streamEvents to capture both tool calls and token-level streaming
const eventStream = agent.streamEvents(initialState, {
...config,
version: 'v2',
...getLangfuseCallbacks(),
});
let finalResult: any = null;
let collectedDocuments: any[] = [];
let currentResponseBuffer = '';
let totalUsage = {
input_tokens: 0,
output_tokens: 0,
total_tokens: 0,
};
let initialMessageSent = false;
// Process the event stream
for await (const event of eventStream) {
if (!initialMessageSent) {
initialMessageSent = true;
// If Firefox AI was detected, emit a special note
if (firefoxAIDetected) {
this.emitter.emit(
'data',
JSON.stringify({
type: 'tool_call',
data: {
content: '<ToolCall type="firefoxAI"></ToolCall>',
},
}),
);
}
}
// Handle different event types
if (
event.event === 'on_chain_end' &&
event.name === 'RunnableSequence'
) {
finalResult = event.data.output;
// Collect relevant documents from the final result
if (finalResult && finalResult.relevantDocuments) {
collectedDocuments.push(...finalResult.relevantDocuments);
}
}
// Collect sources from tool results
if (
event.event === 'on_chain_end' &&
(event.name.includes('search') ||
event.name.includes('Search') ||
event.name.includes('tool') ||
event.name.includes('Tool'))
) {
// Handle LangGraph state updates with relevantDocuments
if (event.data?.output && Array.isArray(event.data.output)) {
for (const item of event.data.output) {
if (
item.update &&
item.update.relevantDocuments &&
Array.isArray(item.update.relevantDocuments)
) {
collectedDocuments.push(...item.update.relevantDocuments);
}
}
}
}
// Handle streaming tool calls (for thought messages)
if (event.event === 'on_chat_model_end' && event.data.output) {
const output = event.data.output;
// Collect token usage from chat model end events
if (output.usage_metadata) {
const normalized = normalizeUsageMetadata(output.usage_metadata);
totalUsage.input_tokens += normalized.input_tokens;
totalUsage.output_tokens += normalized.output_tokens;
totalUsage.total_tokens += normalized.total_tokens;
console.log(
'SimplifiedAgent: Collected usage from usage_metadata:',
normalized,
);
} else if (output.response_metadata?.usage) {
// Fallback to response_metadata for different model providers
const normalized = normalizeUsageMetadata(
output.response_metadata.usage,
);
totalUsage.input_tokens += normalized.input_tokens;
totalUsage.output_tokens += normalized.output_tokens;
totalUsage.total_tokens += normalized.total_tokens;
console.log(
'SimplifiedAgent: Collected usage from response_metadata:',
normalized,
);
}
if (
output._getType() === 'ai' &&
output.tool_calls &&
output.tool_calls.length > 0
) {
const aiMessage = output as AIMessage;
// Process each tool call and emit thought messages
for (const toolCall of aiMessage.tool_calls || []) {
if (toolCall && toolCall.name) {
const toolName = toolCall.name;
const toolArgs = toolCall.args || {};
// Create user-friendly messages for different tools using markdown components
let toolMarkdown = '';
switch (toolName) {
case 'web_search':
toolMarkdown = `<ToolCall type=\"search\" query=\"${encodeHtmlAttribute(toolArgs.query || 'relevant information')}\"></ToolCall>`;
break;
case 'file_search':
toolMarkdown = `<ToolCall type=\"file\" query=\"${encodeHtmlAttribute(toolArgs.query || 'relevant information')}\"></ToolCall>`;
break;
case 'url_summarization':
if (Array.isArray(toolArgs.urls)) {
toolMarkdown = `<ToolCall type="url" count="${toolArgs.urls.length}"></ToolCall>`;
} else {
toolMarkdown = `<ToolCall type="url" count="1"></ToolCall>`;
}
break;
case 'image_search':
toolMarkdown = `<ToolCall type=\"image\" query=\"${encodeHtmlAttribute(toolArgs.query || 'relevant images')}\"></ToolCall>`;
break;
default:
toolMarkdown = `<ToolCall type="${toolName}"></ToolCall>`;
}
// Emit the thought message
this.emitter.emit(
'data',
JSON.stringify({
type: 'tool_call',
data: {
content: toolMarkdown,
},
}),
);
}
}
}
}
// Handle LLM end events for token usage tracking
if (event.event === 'on_llm_end' && event.data.output) {
const output = event.data.output;
// Collect token usage from LLM end events
if (output.llmOutput?.tokenUsage) {
const normalized = normalizeUsageMetadata(
output.llmOutput.tokenUsage,
);
totalUsage.input_tokens += normalized.input_tokens;
totalUsage.output_tokens += normalized.output_tokens;
totalUsage.total_tokens += normalized.total_tokens;
console.log(
'SimplifiedAgent: Collected usage from llmOutput:',
normalized,
);
}
}
// Handle token-level streaming for the final response
if (event.event === 'on_chat_model_stream' && event.data.chunk) {
const chunk = event.data.chunk;
if (chunk.content && typeof chunk.content === 'string') {
// Add the token to our buffer
currentResponseBuffer += chunk.content;
// Emit the individual token
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: chunk.content,
}),
);
}
}
}
// Emit the final sources used for the response
if (collectedDocuments.length > 0) {
this.emitter.emit(
'data',
JSON.stringify({
type: 'sources',
data: collectedDocuments,
searchQuery: '',
searchUrl: '',
}),
);
}
// If we didn't get any streamed tokens but have a final result, emit it
if (
currentResponseBuffer === '' &&
finalResult &&
finalResult.messages &&
finalResult.messages.length > 0
) {
const finalMessage =
finalResult.messages[finalResult.messages.length - 1];
if (finalMessage && finalMessage.content) {
console.log('SimplifiedAgent: Emitting complete response (fallback)');
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: finalMessage.content,
}),
);
}
}
// If we still have no response, emit a fallback message
if (
currentResponseBuffer === '' &&
(!finalResult ||
!finalResult.messages ||
finalResult.messages.length === 0)
) {
console.warn('SimplifiedAgent: No valid response found');
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: 'I apologize, but I was unable to generate a complete response to your query. Please try rephrasing your question or providing more specific details.',
}),
);
}
// Emit model stats and end signal after streaming is complete
const modelName = getModelName(this.llm);
console.log('SimplifiedAgent: Total usage collected:', totalUsage);
this.emitter.emit(
'stats',
JSON.stringify({
type: 'modelStats',
data: {
modelName,
usage: totalUsage,
},
}),
);
this.emitter.emit('end');
} catch (error: any) {
console.error('SimplifiedAgent: Error during search and answer:', error);
// Handle specific error types
if (error.name === 'AbortError' || this.signal.aborted) {
console.warn('SimplifiedAgent: Operation was aborted');
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: 'The search operation was cancelled.',
}),
);
} else {
// General error handling
this.emitter.emit(
'data',
JSON.stringify({
type: 'response',
data: 'I encountered an error while processing your request. Please try rephrasing your query or contact support if the issue persists.',
}),
);
}
this.emitter.emit('end');
}
}
/**
* Get current configuration info
*/
getInfo(): object {
return {
systemInstructions: !!this.systemInstructions,
personaInstructions: !!this.personaInstructions,
};
}
}

View file

@ -23,6 +23,7 @@ import { formatDateForLLM } from '../utils';
import { getDocumentsFromLinks } from '../utils/documents';
import formatChatHistoryAsString from '../utils/formatHistory';
import { getModelName } from '../utils/modelUtils';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
export interface SpeedSearchAgentType {
searchAndAnswer: (
@ -235,8 +236,8 @@ class SpeedSearchAgent implements SpeedSearchAgentType {
</text>
Make sure to answer the query in the summary.
`,
{ signal },
`,
{ signal, ...getLangfuseCallbacks() },
);
const document = new Document({
@ -348,7 +349,7 @@ class SpeedSearchAgent implements SpeedSearchAgentType {
date,
systemInstructions,
},
{ signal: options?.signal },
{ signal: options?.signal, ...getLangfuseCallbacks() },
);
query = searchRetrieverResult.query;
@ -379,6 +380,7 @@ class SpeedSearchAgent implements SpeedSearchAgentType {
)
.withConfig({
runName: 'FinalSourceRetriever',
...getLangfuseCallbacks(),
})
.pipe(this.processDocs),
}),
@ -391,6 +393,7 @@ class SpeedSearchAgent implements SpeedSearchAgentType {
this.strParser,
]).withConfig({
runName: 'FinalResponseGenerator',
...getLangfuseCallbacks(),
});
}
@ -548,6 +551,7 @@ ${docs[index].metadata?.url.toLowerCase().includes('file') ? '' : '\n<url>' + do
version: 'v1',
// Pass the abort signal to the LLM streaming chain
signal,
...getLangfuseCallbacks(),
},
);

View file

@ -0,0 +1,58 @@
import { BaseMessage } from '@langchain/core/messages';
import { Annotation } from '@langchain/langgraph';
import { Document } from 'langchain/document';
/**
* State schema for the simplified chat agent using tool-based workflow
* This state is designed for use with createReactAgent and focuses on
* accumulating relevant documents across tool calls while maintaining
* message history for the agent's decision-making process.
*/
export const SimplifiedAgentState = Annotation.Root({
/**
* Conversation messages - the primary communication channel
* between the user, agent, and tools
*/
messages: Annotation<BaseMessage[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
/**
* Relevant documents accumulated across tool calls
* This is the key state that tools will populate and the synthesizer will consume
*/
relevantDocuments: Annotation<Document[]>({
reducer: (x, y) => x.concat(y),
default: () => [],
}),
/**
* Original user query for context
*/
query: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => '',
}),
/**
* Focus mode for the agent
*/
focusMode: Annotation<string>({
reducer: (x, y) => y ?? x,
default: () => 'webSearch',
}),
/**
* File IDs available for search
*/
fileIds: Annotation<string[]>({
reducer: (x, y) => y ?? x,
default: () => [],
}),
});
/**
* Type definition for the simplified agent state
*/
export type SimplifiedAgentStateType = typeof SimplifiedAgentState.State;

View file

@ -0,0 +1,182 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { RunnableConfig } from '@langchain/core/runnables';
import { Document } from 'langchain/document';
import { Embeddings } from '@langchain/core/embeddings';
import { Command, getCurrentTaskInput } from '@langchain/langgraph';
import { ToolMessage } from '@langchain/core/messages';
import { SimplifiedAgentStateType } from '@/lib/state/chatAgentState';
import {
processFilesToDocuments,
getRankedDocs,
} from '@/lib/utils/fileProcessing';
// Schema for file search tool input
const FileSearchToolSchema = z.object({
query: z
.string()
.describe('The search query to find relevant content in files'),
maxResults: z
.number()
.optional()
.default(12)
.describe('Maximum number of results to return'),
similarityThreshold: z
.number()
.optional()
.default(0.3)
.describe('Minimum similarity threshold for results'),
});
/**
* FileSearchTool - Reimplementation of FileSearchAgent as a tool
*
* This tool handles:
* 1. Processing uploaded files into searchable documents
* 2. Performing similarity search across file content
* 3. Ranking and filtering results by relevance
* 4. Returning relevant file sections as documents
*/
export const fileSearchTool = tool(
async (
input: z.infer<typeof FileSearchToolSchema>,
config?: RunnableConfig,
) => {
try {
const { query, maxResults = 12, similarityThreshold = 0.3 } = input;
const currentState = getCurrentTaskInput() as SimplifiedAgentStateType;
let currentDocCount = currentState.relevantDocuments.length;
// Get fileIds from config (provided by the agent)
const fileIds: string[] = config?.configurable?.fileIds || [];
console.log(
`FileSearchTool: Processing ${fileIds.length} files for query: "${query}"`,
);
// Check if we have files to process
if (!fileIds || fileIds.length === 0) {
console.log('FileSearchTool: No files provided for search');
return new Command({
update: {
relevantDocuments: [],
messages: [
new ToolMessage({
content: 'No files attached to search.',
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
// Get embeddings from config
if (!config?.configurable?.embeddings) {
throw new Error('Embeddings not available in config');
}
const embeddings: Embeddings = config.configurable.embeddings;
// Step 1: Process files to documents
console.log('FileSearchTool: Processing files to documents...');
const fileDocuments = await processFilesToDocuments(fileIds);
if (fileDocuments.length === 0) {
console.log('FileSearchTool: No processable content found in files');
return new Command({
update: {
relevantDocuments: [],
messages: [
new ToolMessage({
content: 'No searchable content found in attached files.',
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
console.log(
`FileSearchTool: Processed ${fileDocuments.length} file sections`,
);
// Step 2: Generate query embedding for similarity search
console.log('FileSearchTool: Generating query embedding...');
const queryEmbedding = await embeddings.embedQuery(query);
// Step 3: Perform similarity search and ranking
console.log('FileSearchTool: Performing similarity search...');
const rankedDocuments = getRankedDocs(
queryEmbedding,
fileDocuments,
maxResults,
similarityThreshold,
);
console.log(
`FileSearchTool: Found ${rankedDocuments.length} relevant file sections`,
);
// Add search metadata to documents and remove embeddings to reduce context size
const documentsWithMetadata = rankedDocuments.map((doc) => {
// Extract metadata and exclude embeddings
const { embeddings: _, ...metadataWithoutEmbeddings } =
doc.metadata || {};
return new Document({
pageContent: doc.pageContent,
metadata: {
...metadataWithoutEmbeddings,
sourceId: ++currentDocCount,
source: 'file_search',
searchQuery: query,
similarityScore: doc.metadata?.similarity || 0,
},
});
});
console.log(
`FileSearchTool: Created ${documentsWithMetadata.length} documents from file search`,
);
return new Command({
update: {
relevantDocuments: documentsWithMetadata,
messages: [
new ToolMessage({
content: JSON.stringify({
documents: documentsWithMetadata,
processedFiles: fileIds.length,
relevantSections: rankedDocuments.length,
}),
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
} catch (error) {
console.error('FileSearchTool: Error during file search:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
return new Command({
update: {
relevantDocuments: [],
messages: [
new ToolMessage({
content: 'Error occurred during file search: ' + errorMessage,
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
},
{
name: 'file_search',
description:
'Searches through all uploaded files to find relevant content sections based on a query using semantic similarity. Automatically searches all available files - no need to specify file IDs.',
schema: FileSearchToolSchema,
},
);

View file

@ -0,0 +1,118 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { RunnableConfig } from '@langchain/core/runnables';
import { Document } from 'langchain/document';
import { searchSearxng } from '@/lib/searxng';
import { Command, getCurrentTaskInput } from '@langchain/langgraph';
import { SimplifiedAgentStateType } from '@/lib/state/chatAgentState';
import { ToolMessage } from '@langchain/core/messages';
// Schema for image search tool input
const ImageSearchToolSchema = z.object({
query: z
.string()
.describe(
'The image search query. Provide a concise description of what images to find.',
),
maxResults: z
.number()
.optional()
.default(12)
.describe('Maximum number of image results to return.'),
});
/**
* ImageSearchTool - Performs image search via SearXNG and returns image results
*
* Responsibilities:
* 1. Execute image-specific search using image engines
* 2. Normalize results to a consistent structure
* 3. Return results as Documents in state (metadata contains image fields)
*/
export const imageSearchTool = tool(
async (
input: z.infer<typeof ImageSearchToolSchema>,
config?: RunnableConfig,
) => {
try {
const { query, maxResults = 12 } = input;
const currentState = getCurrentTaskInput() as SimplifiedAgentStateType;
let currentDocCount = currentState.relevantDocuments.length;
console.log(`ImageSearchTool: Searching images for query: "${query}"`);
const searchResults = await searchSearxng(query, {
language: 'en',
engines: ['bing images', 'google images'],
});
const images = (searchResults.results || [])
.filter((r: any) => r && r.img_src && r.url)
.slice(0, maxResults);
if (images.length === 0) {
return new Command({
update: {
messages: [
new ToolMessage({
content: 'No image results found.',
tool_call_id: (config as any)?.toolCall?.id,
}),
],
},
});
}
const documents: Document[] = images.map(
(img: any) =>
new Document({
pageContent: `${img.title || 'Image'}\n${img.url}`,
metadata: {
sourceId: ++currentDocCount,
title: img.title || 'Image',
url: img.url,
source: img.url,
img_src: img.img_src,
thumbnail: img.thumbnail || undefined,
processingType: 'image-search',
searchQuery: query,
},
}),
);
return new Command({
update: {
relevantDocuments: documents,
messages: [
new ToolMessage({
content: JSON.stringify({ images }),
tool_call_id: (config as any)?.toolCall?.id,
}),
],
},
});
} catch (error) {
console.error('ImageSearchTool: Error during image search:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
return new Command({
update: {
messages: [
new ToolMessage({
content: 'Error occurred during image search: ' + errorMessage,
tool_call_id: (config as any)?.toolCall?.id,
}),
],
},
});
}
},
{
name: 'image_search',
description:
'Searches the web for images related to a query using SearXNG and returns image URLs, titles, and sources. Use when the user asks for pictures, photos, charts, or visual examples.',
schema: ImageSearchToolSchema,
},
);

View file

@ -0,0 +1,53 @@
/**
* Agent Tools for Simplified Chat Agent
*
* This module exports all the tools that reimplement the functionality of the
* existing LangGraph agents for use with createReactAgent. Each tool encapsulates
* the core logic of its corresponding agent and follows the Command pattern for
* state management.
*/
// Import all agent tools (will be uncommented as tools are implemented)
import { taskManagerTool } from './taskManagerTool';
import { simpleWebSearchTool } from './simpleWebSearchTool';
import { fileSearchTool } from './fileSearchTool';
import { imageSearchTool } from './imageSearchTool';
import { urlSummarizationTool } from './urlSummarizationTool';
// Export individual tools (will be uncommented as tools are implemented)
export { taskManagerTool };
export { simpleWebSearchTool };
export { fileSearchTool };
export { imageSearchTool };
// Array containing all available agent tools for the simplified chat agent
// This will be used by the createReactAgent implementation
export const allAgentTools = [
//taskManagerTool,
//webSearchTool,
simpleWebSearchTool,
fileSearchTool,
urlSummarizationTool,
imageSearchTool,
];
// Export tool categories for selective tool loading based on focus mode
export const webSearchTools = [
//webSearchTool,
simpleWebSearchTool,
urlSummarizationTool,
imageSearchTool,
// analyzerTool,
// synthesizerTool,
];
export const fileSearchTools = [
fileSearchTool,
// analyzerTool,
// synthesizerTool,
];
// Core tools that are always available
export const coreTools = [
//taskManagerTool
];

View file

@ -0,0 +1,201 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { RunnableConfig } from '@langchain/core/runnables';
import { withStructuredOutput } from '@/lib/utils/structuredOutput';
import { PromptTemplate } from '@langchain/core/prompts';
import { webSearchRetrieverAgentPrompt } from '@/lib/prompts/webSearch';
import { searchSearxng } from '@/lib/searxng';
import { formatDateForLLM } from '@/lib/utils';
import { Document } from 'langchain/document';
import { Embeddings } from '@langchain/core/embeddings';
import computeSimilarity from '@/lib/utils/computeSimilarity';
import { Command, getCurrentTaskInput } from '@langchain/langgraph';
import { ToolMessage } from '@langchain/core/messages';
import { SimplifiedAgentStateType } from '@/lib/state/chatAgentState';
// Schema for search query generation
const SearchQuerySchema = z.object({
searchQuery: z
.string()
.describe('The optimized search query to use for web search'),
reasoning: z
.string()
.describe(
'A short explanation of how the search query was optimized for better results',
),
});
// Schema for simple web search tool input
const SimpleWebSearchToolSchema = z.object({
query: z.string().describe('The search query or task to process'),
searchInstructions: z
.string()
.optional()
.describe('Additional instructions for search refinement'),
context: z
.string()
.optional()
.describe('Additional context about the search'),
});
/**
* SimpleWebSearchTool - Simplified version of WebSearchTool
*
* This tool handles:
* 1. Query optimization for web search
* 2. Web search execution using SearXNG
* 3. Document ranking and filtering (top 15: top 3 + ranked top 12)
* 4. Returns raw search results as documents without analysis or content extraction
*/
export const simpleWebSearchTool = tool(
async (
input: z.infer<typeof SimpleWebSearchToolSchema>,
config?: RunnableConfig,
) => {
try {
const { query, searchInstructions, context = '' } = input;
const currentState = getCurrentTaskInput() as SimplifiedAgentStateType;
let currentDocCount = currentState.relevantDocuments.length;
// Get LLM and embeddings from config
if (!config?.configurable?.llm) {
throw new Error('LLM not available in config');
}
if (!config?.configurable?.embeddings) {
throw new Error('Embeddings not available in config');
}
const llm = config.configurable.llm;
const embeddings: Embeddings = config.configurable.embeddings;
const searchQuery = query;
console.log(
`SimpleWebSearchTool: Performing web search for query: "${searchQuery}"`,
);
// Step 2: Execute web search
const searchResults = await searchSearxng(searchQuery, {
language: 'en',
engines: [],
});
console.log(
`SimpleWebSearchTool: Found ${searchResults.results.length} search results`,
);
if (!searchResults.results || searchResults.results.length === 0) {
return new Command({
update: {
relevantDocuments: [],
messages: [
new ToolMessage({
content: 'No search results found.',
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
// Step 3: Calculate similarities and rank results
const queryVector = await embeddings.embedQuery(query);
// Calculate similarities for all results
const resultsWithSimilarity = await Promise.all(
searchResults.results.map(async (result) => {
const vector = await embeddings.embedQuery(
result.title + ' ' + (result.content || ''),
);
const similarity = computeSimilarity(vector, queryVector);
return { result, similarity };
}),
);
const documents: Document[] = [];
// Always take the top 3 results first
const top3Results = searchResults.results.slice(0, 3);
documents.push(
...top3Results.map((result, i) => {
return new Document({
pageContent: `${result.title || 'Untitled'}\n\n${result.content || ''}`,
metadata: {
sourceId: ++currentDocCount,
title: result.title || 'Untitled',
url: result.url,
source: result.url,
processingType: 'preview-only',
searchQuery: searchQuery,
rank: 'top-3',
},
});
}),
);
// Sort by relevance score and take top 5 from the remaining results
const remainingResults = resultsWithSimilarity
.slice(3)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, 5);
documents.push(
...remainingResults.map(({ result }) => {
return new Document({
pageContent: `${result.title || 'Untitled'}\n\n${result.content || ''}`,
metadata: {
sourceId: ++currentDocCount,
title: result.title || 'Untitled',
url: result.url,
source: result.url,
processingType: 'preview-only',
searchQuery: searchQuery,
rank: 'ranked',
},
});
}),
);
console.log(
`SimpleWebSearchTool: Created ${documents.length} documents from search results`,
);
//return { documents };
return new Command({
update: {
relevantDocuments: documents,
messages: [
new ToolMessage({
content: JSON.stringify({
document: documents,
}),
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
} catch (error) {
console.error('SimpleWebSearchTool: Error during web search:', error);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
//return { documents: [] };
return new Command({
update: {
relevantDocuments: [],
messages: [
new ToolMessage({
content: 'Error occurred during web search: ' + errorMessage,
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
},
{
name: 'web_search',
description:
'Performs web search using SearXNG and returns ranked search results as documents without content analysis or extraction',
schema: SimpleWebSearchToolSchema,
},
);

View file

@ -0,0 +1,112 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { RunnableConfig } from '@langchain/core/runnables';
import { withStructuredOutput } from '@/lib/utils/structuredOutput';
import { PromptTemplate } from '@langchain/core/prompts';
import { taskBreakdownPrompt } from '@/lib/prompts/taskBreakdown';
// Schema for task manager tool input
const TaskManagerToolSchema = z.object({
query: z.string().describe('The user query to break down into smaller tasks'),
context: z
.string()
.optional()
.describe('Additional context about the query or current situation'),
});
// Schema for structured output
const TaskBreakdownSchema = z.object({
tasks: z
.array(z.string())
.describe(
'Array of specific, focused tasks broken down from the original query',
),
reasoning: z
.string()
.describe(
'Explanation of how and why the query was broken down into these tasks',
),
});
/**
* TaskManagerTool - Breaks down complex queries into manageable task lists
*
* This tool takes a user query and returns a list of specific, actionable tasks
* that can help answer the original question. The tasks are returned as natural
* language instructions that the main agent can follow.
*/
export const taskManagerTool = tool(
async (
input: z.infer<typeof TaskManagerToolSchema>,
config?: RunnableConfig,
): Promise<{ tasks: string[]; reasoning: string }> => {
try {
console.log(
'TaskManagerTool: Starting task breakdown for query:',
input.query,
);
const { query, context = '' } = input;
// Get LLM from config
if (!config?.configurable?.llm) {
throw new Error('LLM not available in config');
}
const llm = config.configurable.llm;
// Create structured LLM for task breakdown
const structuredLLM = withStructuredOutput(llm, TaskBreakdownSchema, {
name: 'task_breakdown',
includeRaw: false,
});
// Create the prompt template
const template = PromptTemplate.fromTemplate(taskBreakdownPrompt);
// Format the prompt with the query and context
const prompt = await template.format({
systemInstructions:
config.configurable?.systemInstructions ||
'You are a helpful AI assistant.',
fileContext: context || 'No additional context provided.',
query: query,
currentTasks: 0,
taskHistory: 'No previous tasks.',
});
// Get the task breakdown from the LLM
const response = await structuredLLM.invoke(prompt, {
signal: config?.signal,
});
if (!response?.tasks || response.tasks.length === 0) {
// If no breakdown is needed, return the original query as a single task
return {
tasks: [query],
reasoning:
'The query is straightforward and does not require breaking down into smaller tasks.',
};
}
return {
tasks: response.tasks,
reasoning: response.reasoning,
};
} catch (error) {
console.error('Error in TaskManagerTool:', error);
// Fallback: return the original query as a single task
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
return {
tasks: [input.query],
reasoning: `Error occurred during task breakdown: ${errorMessage}. Proceeding with the original query.`,
};
}
},
{
name: 'task_manager',
description:
'Breaks down complex user queries into a list of specific, manageable tasks that can be executed to answer the original question',
schema: TaskManagerToolSchema,
},
);

View file

@ -0,0 +1,233 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { RunnableConfig } from '@langchain/core/runnables';
import { Document } from 'langchain/document';
import { getWebContent } from '@/lib/utils/documents';
import { removeThinkingBlocks } from '@/lib/utils/contentUtils';
import { Command, getCurrentTaskInput } from '@langchain/langgraph';
import { SimplifiedAgentStateType } from '@/lib/state/chatAgentState';
import { ToolMessage } from '@langchain/core/messages';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
// Schema for URL summarization tool input
const URLSummarizationToolSchema = z.object({
urls: z.array(z.string()).describe('Array of URLs to process and summarize'),
query: z
.string()
.describe('The user query to guide content extraction and summarization'),
retrieveHtml: z
.boolean()
.optional()
.default(false)
.describe('Whether to retrieve the full HTML content of the pages'),
intent: z
.string()
.optional()
.default('extract relevant content')
.describe('Processing intent for the URLs'),
});
/**
* URLSummarizationTool - Reimplementation of URLSummarizationAgent as a tool
*
* This tool handles:
* 1. Fetching content from provided URLs
* 2. Deciding whether to use content directly or summarize it
* 3. Generating summaries using LLM when content is too long
* 4. Returning processed documents with metadata
*/
export const urlSummarizationTool = tool(
async (
input: z.infer<typeof URLSummarizationToolSchema>,
config?: RunnableConfig,
) => {
try {
const {
urls,
query,
retrieveHtml = false,
intent = 'extract relevant content',
} = input;
const currentState = getCurrentTaskInput() as SimplifiedAgentStateType;
let currentDocCount = currentState.relevantDocuments.length;
console.log(
`URLSummarizationTool: Processing ${urls.length} \n URLs for query: "${query}"\n retrieveHtml: ${retrieveHtml}\n intent: ${intent}`,
);
if (!urls || urls.length === 0) {
console.log('URLSummarizationTool: No URLs provided for processing');
return new Command({
update: {
messages: [
new ToolMessage({
content: 'No search results found.',
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
// Get LLM from config
if (!config?.configurable?.llm) {
throw new Error('LLM not available in config');
}
const llm = config.configurable.llm;
const documents: Document[] = [];
// Process each URL
for (const url of urls) {
if (config?.signal?.aborted) {
console.warn('URLSummarizationTool: Operation aborted by signal');
break;
}
try {
console.log(`URLSummarizationTool: Processing ${url}`);
// Fetch full content using the enhanced web content retrieval
const webContent = await getWebContent(url, retrieveHtml);
if (!webContent || !webContent.pageContent) {
console.warn(
`URLSummarizationTool: No content retrieved from URL: ${url}`,
);
continue;
}
const contentLength = webContent.pageContent.length;
let finalContent: string;
let processingType: string;
// If content is short (< 4000 chars), use it directly; otherwise summarize
if (contentLength < 4000) {
finalContent = webContent.pageContent;
processingType = 'url-direct-content';
console.log(
`URLSummarizationTool: Content is short (${contentLength} chars), using directly without summarization`,
);
} else {
// Content is long, summarize using LLM
console.log(
`URLSummarizationTool: Content is long (${contentLength} chars), generating summary`,
);
const systemPrompt = config.configurable?.systemInstructions
? `${config.configurable.systemInstructions}\n\n`
: '';
const summarizationPrompt = `${systemPrompt}You are a web content processor. Extract and summarize ONLY the information from the provided web page content that is relevant to the user's query.
# Critical Instructions
- Output ONLY a summary of the web page content provided below
- Focus on information that relates to or helps answer the user's query and processing intent
- Do NOT add pleasantries, greetings, or conversational elements
- Do NOT mention missing URLs, other pages, or content not provided
- Do NOT ask follow-up questions or suggest additional actions
- Do NOT add commentary about the user's request or query
- Present the information in a clear, well-structured format with key facts and details
- Include all relevant details that could help answer the user's question
# User's Query: ${query}
# Processing Intent: ${intent}
# Content Title: ${webContent.metadata.title || 'Web Page'}
# Content URL: ${url}
# Web Page Content to Summarize:
${retrieveHtml && webContent.metadata?.html ? webContent.metadata.html : webContent.pageContent}
Provide a comprehensive summary of the above web page content, focusing on information relevant to the user's query:`;
const result = await llm.invoke(summarizationPrompt, {
signal: config?.signal,
...getLangfuseCallbacks(),
});
finalContent = removeThinkingBlocks(result.content as string);
processingType = 'url-content-extraction';
}
// Web content less than 100 characters probably isn't useful so discard it.
if (finalContent && finalContent.trim().length > 100) {
const document = new Document({
pageContent: finalContent,
metadata: {
sourceId: ++currentDocCount,
title: webContent.metadata.title || 'URL Content',
url: url,
source: url,
processingType: processingType,
processingIntent: intent,
originalContentLength: contentLength,
searchQuery: query,
},
});
documents.push(document);
console.log(
`URLSummarizationTool: Successfully processed content from ${url} (${finalContent.length} characters, ${processingType})`,
);
} else {
console.warn(
`URLSummarizationTool: No valid content generated for URL: ${url}`,
);
}
} catch (error) {
console.error(
`URLSummarizationTool: Error processing URL ${url}:`,
error,
);
continue;
}
}
console.log(
`URLSummarizationTool: Successfully processed ${documents.length} out of ${urls.length} URLs`,
);
return new Command({
update: {
relevantDocuments: documents,
messages: [
new ToolMessage({
content: JSON.stringify({
document: documents,
}),
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
} catch (error) {
console.error(
'URLSummarizationTool: Error during URL processing:',
error,
);
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
return new Command({
update: {
messages: [
new ToolMessage({
content: 'Error occurred during URL processing: ' + errorMessage,
tool_call_id: (config as any)?.toolCall.id,
}),
],
},
});
}
},
{
name: 'url_summarization',
description:
'Fetches content from URLs and either uses it directly or summarizes it based on length, focusing on information relevant to the user query. URLs must be real and should not be invented.',
schema: URLSummarizationToolSchema,
},
);

View file

@ -0,0 +1,143 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { DateTime, Interval } from 'luxon';
import { parseDate, getDateParseErrorMessage } from '@/lib/utils/dates';
/**
* Tool that calculates the difference between two dates
*/
export const dateDifferenceTool = tool(
({ startDate, endDate }: { startDate: string; endDate: string }): string => {
try {
console.log(
`Calculating difference between "${startDate}" and "${endDate}"`,
);
// Parse the dates using the extracted utility function
const startDateTime = parseDate(startDate);
const endDateTime = parseDate(endDate);
// Check if dates are valid
if (!startDateTime.isValid) {
return getDateParseErrorMessage(startDate, startDateTime, 'start date');
}
if (!endDateTime.isValid) {
return getDateParseErrorMessage(endDate, endDateTime, 'end date');
}
// Create an interval between the two dates for accurate calculations
const interval = Interval.fromDateTimes(startDateTime, endDateTime);
if (!interval.isValid) {
return `Error: Invalid interval between dates. Reason: ${interval.invalidReason}`;
}
// Calculate differences in various units using Luxon's accurate methods
const diffMilliseconds = Math.abs(
endDateTime.diff(startDateTime).toMillis(),
);
const diffSeconds = Math.abs(
endDateTime.diff(startDateTime, 'seconds').seconds,
);
const diffMinutes = Math.abs(
endDateTime.diff(startDateTime, 'minutes').minutes,
);
const diffHours = Math.abs(
endDateTime.diff(startDateTime, 'hours').hours,
);
const diffDays = Math.abs(endDateTime.diff(startDateTime, 'days').days);
const diffWeeks = Math.abs(
endDateTime.diff(startDateTime, 'weeks').weeks,
);
const diffMonths = Math.abs(
endDateTime.diff(startDateTime, 'months').months,
);
const diffYears = Math.abs(
endDateTime.diff(startDateTime, 'years').years,
);
// Get multi-unit breakdown for more human-readable output
const multiUnitDiff = endDateTime
.diff(startDateTime, [
'years',
'months',
'days',
'hours',
'minutes',
'seconds',
])
.toObject();
// Determine which date is earlier
const isStartEarlier = startDateTime <= endDateTime;
const earlierDate = isStartEarlier ? startDateTime : endDateTime;
const laterDate = isStartEarlier ? endDateTime : startDateTime;
// Format the dates for display with ISO format
const formatDate = (dt: DateTime) => {
return `${dt.toLocaleString(DateTime.DATETIME_FULL)} (${dt.toISO()})`;
};
let result = `Date difference calculation:
From: ${formatDate(earlierDate)}
To: ${formatDate(laterDate)}
Human-readable breakdown:`;
// Add human-readable breakdown
if (multiUnitDiff.years && Math.abs(multiUnitDiff.years) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.years))} year${Math.abs(multiUnitDiff.years) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.months && Math.abs(multiUnitDiff.months) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.months))} month${Math.abs(multiUnitDiff.months) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.days && Math.abs(multiUnitDiff.days) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.days))} day${Math.abs(multiUnitDiff.days) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.hours && Math.abs(multiUnitDiff.hours) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.hours))} hour${Math.abs(multiUnitDiff.hours) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.minutes && Math.abs(multiUnitDiff.minutes) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.minutes))} minute${Math.abs(multiUnitDiff.minutes) >= 2 ? 's' : ''}`;
}
if (multiUnitDiff.seconds && Math.abs(multiUnitDiff.seconds) >= 1) {
result += `\n• ${Math.floor(Math.abs(multiUnitDiff.seconds))} second${Math.abs(multiUnitDiff.seconds) >= 2 ? 's' : ''}`;
}
result += `\n\nTotal measurements:
Total years: ${diffYears.toFixed(2)}
Total months: ${diffMonths.toFixed(2)}
Total weeks: ${diffWeeks.toFixed(2)}
Total days: ${diffDays.toFixed(2)}
Total hours: ${diffHours.toFixed(2)}
Total minutes: ${diffMinutes.toFixed(2)}
Total seconds: ${diffSeconds.toFixed(2)}
Total milliseconds: ${diffMilliseconds}
Direction: Start date is ${isStartEarlier ? 'earlier than' : 'later than'} the end date.`;
return result;
} catch (error) {
console.error('Error during date difference calculation:', error);
return `Error: ${error instanceof Error ? error.message : 'Unknown error occurred during date difference calculation'}`;
}
},
{
name: 'date_difference',
description:
'Get the difference between two dates (Works best with ISO 8601 formatted dates)',
schema: z.object({
startDate: z
.string()
.describe(
'The start date (e.g., "2024-01-15", "Jan 15, 2024", "2024-01-15 14:30:00Z", "2024-01-15T14:30:00-05:00")',
),
endDate: z
.string()
.describe(
'The end date (e.g., "2024-12-25", "Dec 25, 2024", "2024-12-25 18:00:00Z", "2024-12-25T18:00:00-05:00")',
),
}),
},
);

13
src/lib/tools/index.ts Normal file
View file

@ -0,0 +1,13 @@
import { timezoneConverterTool } from './timezoneConverter';
import { dateDifferenceTool } from './dateDifference';
// Agent tools for simplified chat agent (will be uncommented as implemented)
// import { allAgentTools } from './agents';
export { timezoneConverterTool, dateDifferenceTool };
// Export agent tools module (will be uncommented as implemented)
// export * from './agents';
// Array containing all available tools
export const allTools = [timezoneConverterTool, dateDifferenceTool];

View file

@ -0,0 +1,70 @@
import { tool } from '@langchain/core/tools';
import { z } from 'zod';
import { DateTime } from 'luxon';
import { parseDate, getDateParseErrorMessage } from '@/lib/utils/dates';
/**
* Tool that converts a date from one timezone to another
*/
export const timezoneConverterTool = tool(
({
dateString,
toTimezone,
}: {
dateString: string;
toTimezone: string;
}): string => {
try {
console.log(
`Converting date "${dateString}" to timezone "${toTimezone}"`,
);
// Parse the date string using the extracted utility function
const dateTime = parseDate(dateString);
// Check if the parsed date is valid
if (!dateTime.isValid) {
return getDateParseErrorMessage(dateString, dateTime);
}
// Convert to target timezone
const targetDateTime = dateTime.setZone(toTimezone);
// Check if the target timezone is valid
if (!targetDateTime.isValid) {
return `Error: Invalid timezone "${toTimezone}". Please use valid timezone identifiers like "America/New_York", "Europe/London", "Asia/Tokyo", etc. Reason: ${targetDateTime.invalidReason}`;
}
// Format both dates as ISO 8601 with timezone offset
const sourceISO = dateTime.toISO();
const targetISO = targetDateTime.toISO();
const output = `Date conversion result:
Source: ${sourceISO} (${dateTime.zoneName})
Target: ${targetISO} (${targetDateTime.zoneName})`;
console.log(output);
return output;
} catch (error) {
console.error('Error during timezone conversion:', error);
return `Error: ${error instanceof Error ? error.message : 'Unknown error occurred during timezone conversion'}`;
}
},
{
name: 'timezone_converter',
description:
'Convert a date from one timezone to another (Works best with ISO 8601 formatted dates) - Expects target timezone in the IANA format (e.g., "America/New_York", "Europe/London", etc.)',
schema: z.object({
dateString: z
.string()
.describe(
'The date string to convert. This must include the timezone offset or Z for UTC (e.g., "2023-10-01T00:00:00Z" or "2025-08-10T00:00:00-06:00")',
),
toTimezone: z
.string()
.describe(
'Target timezone to convert to (e.g., "Asia/Tokyo", "America/Los_Angeles", "Europe/Paris")',
),
}),
},
);

View file

@ -0,0 +1,34 @@
// Centralized Langfuse tracing utility
// Provides a singleton CallbackHandler and a helper to attach callbacks
import type { Callbacks } from '@langchain/core/callbacks/manager';
import { CallbackHandler } from 'langfuse-langchain';
let handler: CallbackHandler | null = null;
export function getLangfuseHandler(): CallbackHandler | null {
// Only initialize on server
if (typeof window !== 'undefined') return null;
if (handler) return handler;
try {
// The handler reads LANGFUSE_* env vars by default. You can also pass keys here if desired.
handler = new CallbackHandler({
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
secretKey: process.env.LANGFUSE_SECRET_KEY,
baseUrl: process.env.LANGFUSE_BASE_URL,
});
} catch (e) {
// If initialization fails (e.g., missing envs), disable tracing gracefully
handler = null;
}
return handler;
}
// Convenience helper to spread into LangChain invoke/config objects
export function getLangfuseCallbacks(): { callbacks?: Callbacks } {
const h = getLangfuseHandler();
return h ? { callbacks: [h] } : {};
}

19
src/lib/types/api.ts Normal file
View file

@ -0,0 +1,19 @@
// API request/response types
import { Source } from './widget';
export interface WidgetProcessRequest {
sources: Source[];
prompt: string;
provider: string;
model: string;
tool_names?: string[];
}
export interface WidgetProcessResponse {
content: string;
success: boolean;
sourcesFetched?: number;
totalSources?: number;
warnings?: string[];
error?: string;
}

8
src/lib/types/cache.ts Normal file
View file

@ -0,0 +1,8 @@
// Cache-related types
export interface WidgetCache {
[widgetId: string]: {
content: string;
lastFetched: Date;
expiresAt: Date;
};
}

View file

@ -0,0 +1,44 @@
// Dashboard configuration and state types
import { Widget, WidgetLayout } from './widget';
import { Layout } from 'react-grid-layout';
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'];
}
// Layout item for react-grid-layout (extends WidgetLayout with required 'i' property)
export interface GridLayoutItem extends WidgetLayout {
i: string; // Widget ID
}
// Layout configuration for responsive grid (compatible with react-grid-layout)
export interface DashboardLayouts {
lg: Layout[];
md: Layout[];
sm: Layout[];
xs: Layout[];
xxs: Layout[];
[key: string]: Layout[]; // Index signature for react-grid-layout compatibility
}
// Local storage keys
export const DASHBOARD_STORAGE_KEYS = {
WIDGETS: 'perplexica_dashboard_widgets',
SETTINGS: 'perplexica_dashboard_settings',
CACHE: 'perplexica_dashboard_cache',
LAYOUTS: 'perplexica_dashboard_layouts',
} as const;

38
src/lib/types/widget.ts Normal file
View file

@ -0,0 +1,38 @@
// Core domain types for widgets
export interface Source {
url: string;
type: 'Web Page' | 'HTTP Data';
}
// Grid layout properties for widgets (only position and size data that should be persisted)
export interface WidgetLayout {
x: number;
y: number;
w: number;
h: number;
static?: boolean;
isDraggable?: boolean;
isResizable?: boolean;
}
export interface WidgetConfig {
id?: string;
title: string;
sources: Source[];
prompt: string;
provider: string;
model: string;
refreshFrequency: number;
refreshUnit: 'minutes' | 'hours';
tool_names?: string[];
layout?: WidgetLayout;
}
export interface Widget extends WidgetConfig {
id: string;
lastUpdated: Date | null;
isLoading: boolean;
content: string | null;
error: string | null;
layout: WidgetLayout;
}

View file

@ -5,6 +5,7 @@ import { formatDateForLLM } from '../utils';
import { ChatOpenAI, OpenAIClient } from '@langchain/openai';
import { removeThinkingBlocks } from './contentUtils';
import { withStructuredOutput } from './structuredOutput';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
export type PreviewAnalysisResult = {
isSufficient: boolean;
@ -88,7 +89,7 @@ Snippet: ${content.snippet}
- Analyze the provided search result previews (titles + snippets), and chat history context to determine if they collectively contain enough information to provide a complete and accurate answer to the Task Query
- If the preview content can provide a complete answer to the Task Query, consider it sufficient
- If the preview content lacks important details, requires deeper analysis, or cannot fully answer the Task Query, consider it insufficient
- Be specific in your reasoning when the content is not sufficient
- Be specific in your reasoning when the content is not sufficient but keep the answer under 35 words
- The original query is provided for additional context, only use it for clarification of overall expectations and intent. You do **not** need to answer the original query directly or completely
# System Instructions
@ -118,7 +119,7 @@ ${taskQuery}
# Search Result Previews to Analyze:
${formattedPreviewContent}
`,
{ signal },
{ signal, ...getLangfuseCallbacks() },
);
if (!analysisResult) {

View file

@ -0,0 +1,213 @@
/**
* Color utility functions for theme calculations and accessibility
* Based on WCAG 2.1 contrast ratio guidelines
*/
/**
* Converts hex color to RGB values
* @param hex - Hex color string (e.g., '#ff0000' or '#f00')
* @returns RGB object with r, g, b values (0-255)
*/
export function hexToRgb(
hex: string,
): { r: number; g: number; b: number } | null {
// Remove the hash if present
hex = hex.replace('#', '');
// Convert 3-digit hex to 6-digit
if (hex.length === 3) {
hex = hex
.split('')
.map((char) => char + char)
.join('');
}
if (hex.length !== 6) {
return null;
}
const r = parseInt(hex.substring(0, 2), 16);
const g = parseInt(hex.substring(2, 4), 16);
const b = parseInt(hex.substring(4, 6), 16);
return { r, g, b };
}
/**
* Converts RGB values to hex color
* @param r - Red value (0-255)
* @param g - Green value (0-255)
* @param b - Blue value (0-255)
* @returns Hex color string
*/
export function rgbToHex(r: number, g: number, b: number): string {
return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)}`;
}
/**
* Converts sRGB color component to linear RGB
* @param colorComponent - Color component (0-255)
* @returns Linear RGB component (0-1)
*/
function sRGBToLinear(colorComponent: number): number {
const c = colorComponent / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
}
/**
* Calculates the relative luminance of a color according to WCAG 2.1
* @param hex - Hex color string
* @returns Relative luminance (0-1)
*/
export function calculateLuminance(hex: string): number {
const rgb = hexToRgb(hex);
if (!rgb) return 0;
const { r, g, b } = rgb;
// Convert to linear RGB
const linearR = sRGBToLinear(r);
const linearG = sRGBToLinear(g);
const linearB = sRGBToLinear(b);
// Calculate relative luminance using WCAG formula
return 0.2126 * linearR + 0.7152 * linearG + 0.0722 * linearB;
}
/**
* Calculates the contrast ratio between two colors according to WCAG 2.1
* @param color1 - First hex color
* @param color2 - Second hex color
* @returns Contrast ratio (1-21)
*/
export function calculateContrastRatio(color1: string, color2: string): number {
const luminance1 = calculateLuminance(color1);
const luminance2 = calculateLuminance(color2);
const lighter = Math.max(luminance1, luminance2);
const darker = Math.min(luminance1, luminance2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Determines if a color is considered "light" (high luminance)
* @param hex - Hex color string
* @returns true if color is light
*/
export function isLightColor(hex: string): boolean {
return calculateLuminance(hex) > 0.5;
}
/**
* Gets appropriate text color (black or white) for maximum contrast
* @param backgroundHex - Background hex color
* @returns '#000000' for light backgrounds, '#ffffff' for dark backgrounds
*/
export function getContrastingTextColor(backgroundHex: string): string {
return isLightColor(backgroundHex) ? '#000000' : '#ffffff';
}
/**
* Checks if color combination meets WCAG contrast requirements
* @param foregroundHex - Foreground color hex
* @param backgroundHex - Background color hex
* @param level - WCAG level ('AA' | 'AAA')
* @param size - Text size ('normal' | 'large')
* @returns true if contrast ratio is sufficient
*/
export function meetsContrastRequirement(
foregroundHex: string,
backgroundHex: string,
level: 'AA' | 'AAA' = 'AA',
size: 'normal' | 'large' = 'normal',
): boolean {
const contrastRatio = calculateContrastRatio(foregroundHex, backgroundHex);
// WCAG 2.1 contrast requirements
const requirements = {
AA: { normal: 4.5, large: 3.0 },
AAA: { normal: 7.0, large: 4.5 },
};
return contrastRatio >= requirements[level][size];
}
/**
* Adjusts color brightness by a percentage
* @param hex - Hex color string
* @param percent - Brightness adjustment percentage (-100 to 100)
* @returns Adjusted hex color
*/
export function adjustBrightness(hex: string, percent: number): string {
const rgb = hexToRgb(hex);
if (!rgb) return hex;
const adjust = (color: number): number => {
const adjusted = color + (percent / 100) * 255;
return Math.max(0, Math.min(255, Math.round(adjusted)));
};
return rgbToHex(adjust(rgb.r), adjust(rgb.g), adjust(rgb.b));
}
/**
* Creates a hover variant of a color (slightly darker/lighter)
* @param hex - Base hex color
* @param amount - Adjustment amount (default: 10% darker for light colors, 15% lighter for dark)
* @returns Hover variant hex color
*/
export function createHoverVariant(hex: string, amount?: number): string {
const defaultAmount = isLightColor(hex) ? -10 : 15;
return adjustBrightness(hex, amount ?? defaultAmount);
}
/**
* Creates a secondary variant of a color (more subtle)
* @param hex - Base hex color
* @param opacity - Opacity factor (0-1, default: 0.1)
* @returns Secondary variant hex color (mixed with appropriate base)
*/
export function createSecondaryVariant(
hex: string,
opacity: number = 0.1,
): string {
const rgb = hexToRgb(hex);
if (!rgb) return hex;
// For light colors, mix with black to make slightly darker
// For dark colors, mix with white to make slightly lighter
const base = isLightColor(hex) ? 0 : 255;
const mix = (color: number): number => {
return Math.round(color * (1 - opacity) + base * opacity);
};
return rgbToHex(mix(rgb.r), mix(rgb.g), mix(rgb.b));
}
/**
* Validates and normalizes hex color format
* @param hex - Input hex color (with or without #)
* @returns Normalized hex color string or null if invalid
*/
export function normalizeHexColor(hex: string): string | null {
// Remove any whitespace
hex = hex.trim();
// Add # if missing
if (!hex.startsWith('#')) {
hex = '#' + hex;
}
// Convert 3-digit to 6-digit
if (hex.length === 4) {
hex = '#' + hex[1] + hex[1] + hex[2] + hex[2] + hex[3] + hex[3];
}
// Validate final format
if (!/^#[0-9A-Fa-f]{6}$/.test(hex)) {
return null;
}
return hex.toLowerCase();
}

52
src/lib/utils/dates.ts Normal file
View file

@ -0,0 +1,52 @@
import { DateTime } from 'luxon';
/**
* Parses a date string using multiple Luxon formats with fallback to JavaScript Date parsing.
* Preserves timezone information when available using setZone: true.
*
* @param dateString - The date string to parse
* @returns A parsed DateTime object
*/
export function parseDate(dateString: string): DateTime {
// Try to parse as ISO format first (most common)
let dateTime = DateTime.fromISO(dateString, { setZone: true });
// If ISO parsing fails, try other common formats
if (!dateTime.isValid) {
dateTime = DateTime.fromRFC2822(dateString, { setZone: true });
}
if (!dateTime.isValid) {
dateTime = DateTime.fromHTTP(dateString, { setZone: true });
}
if (!dateTime.isValid) {
dateTime = DateTime.fromSQL(dateString, { setZone: true });
}
// If all parsing attempts fail, try JavaScript Date parsing as fallback
if (!dateTime.isValid) {
const jsDate = new Date(dateString);
if (!isNaN(jsDate.getTime())) {
dateTime = DateTime.fromJSDate(jsDate);
}
}
return dateTime;
}
/**
* Generates a standardized error message for date parsing failures.
*
* @param dateString - The original date string that failed to parse
* @param dateTime - The invalid DateTime object
* @param fieldName - Optional field name for more specific error messages (e.g., "start date", "end date")
* @returns A formatted error message
*/
export function getDateParseErrorMessage(
dateString: string,
dateTime: DateTime,
fieldName: string = 'date',
): string {
return `Error: Unable to parse ${fieldName} "${dateString}". Please provide a valid date format (ISO 8601, RFC 2822, SQL, or common date formats). Reason: ${dateTime.invalidReason}`;
}

View file

@ -178,11 +178,41 @@ export const getWebContent = async (
// Fallback to CheerioWebBaseLoader for simpler content extraction
try {
console.log(`Fallback to Cheerio for URL: ${url}`);
const cheerioLoader = new CheerioWebBaseLoader(url);
const cheerioLoader = new CheerioWebBaseLoader(url, { maxRetries: 2 });
const docs = await cheerioLoader.load();
if (docs && docs.length > 0) {
return docs[0];
const doc = docs[0];
// Apply Readability to extract meaningful content from Cheerio HTML
const dom = new JSDOM(doc.pageContent, { url });
const reader = new Readability(dom.window.document, {
charThreshold: 25,
});
const article = reader.parse();
// Normalize the text content
const normalizedText =
article?.textContent
?.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0)
.join('\n') || '';
const returnDoc = new Document({
pageContent: normalizedText,
metadata: {
title: article?.title || doc.metadata.title || '',
url: url,
html: getHtml ? article?.content : undefined,
},
});
console.log(
`Got content with Cheerio fallback + Readability, URL: ${url}, Text Length: ${returnDoc.pageContent.length}`,
);
return returnDoc;
}
} catch (fallbackError) {
console.error(

27
src/lib/utils/html.ts Normal file
View file

@ -0,0 +1,27 @@
export function encodeHtmlAttribute(value: string): string {
if (!value) return '';
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
export function decodeHtmlEntities(value: string): string {
if (!value) return '';
const numericDecoded = value
.replace(/&#(\d+);/g, (_, dec) => String.fromCharCode(parseInt(dec, 10)))
.replace(/&#x([\da-fA-F]+);/g, (_, hex) =>
String.fromCharCode(parseInt(hex, 16)),
);
return numericDecoded
.replaceAll('&quot;', '"')
.replaceAll('&apos;', "'")
.replaceAll('&#39;', "'")
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&');
}

View file

@ -6,6 +6,7 @@ import { getWebContent } from './documents';
import { removeThinkingBlocks } from './contentUtils';
import { setTemperature } from './modelUtils';
import { withStructuredOutput } from './structuredOutput';
import { getLangfuseCallbacks } from '@/lib/tracing/langfuse';
export type SummarizeResult = {
document: Document | null;
@ -19,7 +20,9 @@ const RelevanceCheckSchema = z.object({
.describe('Whether the content is relevant to the user query'),
reason: z
.string()
.describe("Brief explanation of why content is or isn't relevant"),
.describe(
"Brief explanation of why content is or isn't relevant. 20 words or less.",
),
});
export const summarizeWebContent = async (
@ -93,7 +96,7 @@ Here is the query you need to answer: ${query}
Here is the content to analyze:
${contentToAnalyze}`,
{ signal },
{ signal, ...getLangfuseCallbacks() },
);
if (!relevanceResult) {
@ -166,7 +169,10 @@ Here is the query you need to answer: ${query}
Here is the content to summarize:
${i === 0 ? content.metadata.html : content.pageContent}`;
const result = await llm.invoke(prompt, { signal });
const result = await llm.invoke(prompt, {
signal,
...getLangfuseCallbacks(),
});
summary = removeThinkingBlocks(result.content as string);
break;
} catch (error) {

View file

@ -1,52 +1,3 @@
import type { Config } from 'tailwindcss';
import type { DefaultColors } from 'tailwindcss/types/generated/colors';
const themeDark = (colors: DefaultColors) => ({
50: '#0a0a0a',
100: '#111111',
200: '#1c1c1c',
});
const themeLight = (colors: DefaultColors) => ({
50: '#fcfcf9',
100: '#f3f3ee',
200: '#e8e8e3',
});
const config: Config = {
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
darkMode: 'class',
theme: {
extend: {
borderColor: ({ colors }) => {
return {
light: themeLight(colors),
dark: themeDark(colors),
};
},
colors: ({ colors }) => {
const colorsDark = themeDark(colors);
const colorsLight = themeLight(colors);
return {
dark: {
primary: colorsDark[50],
secondary: colorsDark[100],
...colorsDark,
},
light: {
primary: colorsLight[50],
secondary: colorsLight[100],
...colorsLight,
},
};
},
},
},
plugins: [require('@tailwindcss/typography')],
};
export default config;
// Tailwind v4 uses CSS-first configuration via @theme in globals.css.
// Keeping an empty config to satisfy tooling that expects this file.
export default {};

Some files were not shown because too many files have changed in this diff Show more