commit
bb8b143bb8
101 changed files with 9808 additions and 6138 deletions
12
.github/copilot-instructions.md
vendored
12
.github/copilot-instructions.md
vendored
|
|
@ -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
|
||||
|
|
|
|||
43
README.md
43
README.md
|
|
@ -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
|
||||
|
||||
[](https://usw.sealos.io/?openapp=system-template%3FtemplateName%3Dperplexica)
|
||||
[](https://repocloud.io/details/?app_id=267)
|
||||
[](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
|
||||
|
|
|
|||
43
docs/installation/TRACING.md
Normal file
43
docs/installation/TRACING.md
Normal 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
1755
package-lock.json
generated
File diff suppressed because it is too large
Load diff
14
package.json
14
package.json
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
'@tailwindcss/postcss': {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: '',
|
||||
};
|
||||
|
|
|
|||
266
src/app/api/dashboard/process-widget/route.ts
Normal file
266
src/app/api/dashboard/process-widget/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
20
src/app/api/tools/route.ts
Normal file
20
src/app/api/tools/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(', ');
|
||||
|
|
|
|||
|
|
@ -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}¤t=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}¤t=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
283
src/app/dashboard/page.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
"{event.details.searchQuery}"
|
||||
</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">
|
||||
"{event.details.currentTask}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{event.details.nextTask && (
|
||||
<div className="flex space-x-1">
|
||||
<span className="font-bold whitespace-nowrap">
|
||||
Next:
|
||||
</span>
|
||||
<span className="italic">
|
||||
"{event.details.nextTask}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{event.details.currentSearchFocus && (
|
||||
<div className="flex space-x-1">
|
||||
<span className="font-bold whitespace-nowrap">
|
||||
Search Focus:
|
||||
</span>
|
||||
<span className="italic">
|
||||
"{event.details.currentSearchFocus}"
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentActionDisplay;
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
86
src/components/CitationLink.tsx
Normal file
86
src/components/CitationLink.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
400
src/components/MarkdownRenderer.tsx
Normal file
400
src/components/MarkdownRenderer.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
166
src/components/MessageInputActions/ToolSelector.tsx
Normal file
166
src/components/MessageInputActions/ToolSelector.tsx
Normal 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;
|
||||
102
src/components/MessageSource.tsx
Normal file
102
src/components/MessageSource.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
534
src/components/dashboard/WidgetConfigModal.tsx
Normal file
534
src/components/dashboard/WidgetConfigModal.tsx
Normal 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 "Run Preview" 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;
|
||||
194
src/components/dashboard/WidgetDisplay.tsx
Normal file
194
src/components/dashboard/WidgetDisplay.tsx
Normal 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;
|
||||
209
src/components/theme/Controller.tsx
Normal file
209
src/components/theme/Controller.tsx
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
117
src/components/ui/card.tsx
Normal 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,
|
||||
};
|
||||
|
|
@ -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,
|
||||
}),
|
||||
});
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
46
src/lib/constants/dashboard.ts
Normal file
46
src/lib/constants/dashboard.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
605
src/lib/hooks/useDashboard.ts
Normal file
605
src/lib/hooks/useDashboard.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
61
src/lib/prompts/simplifiedAgent/chat.ts
Normal file
61
src/lib/prompts/simplifiedAgent/chat.ts
Normal 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.`;
|
||||
}
|
||||
60
src/lib/prompts/simplifiedAgent/firefoxAI.ts
Normal file
60
src/lib/prompts/simplifiedAgent/firefoxAI.ts
Normal 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}`
|
||||
: ''
|
||||
}
|
||||
`;
|
||||
}
|
||||
107
src/lib/prompts/simplifiedAgent/localResearch.ts
Normal file
107
src/lib/prompts/simplifiedAgent/localResearch.ts
Normal 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.`;
|
||||
}
|
||||
155
src/lib/prompts/simplifiedAgent/webSearch.ts
Normal file
155
src/lib/prompts/simplifiedAgent/webSearch.ts
Normal 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}`
|
||||
: ''
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
587
src/lib/search/simplifiedAgent.ts
Normal file
587
src/lib/search/simplifiedAgent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
58
src/lib/state/chatAgentState.ts
Normal file
58
src/lib/state/chatAgentState.ts
Normal 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;
|
||||
182
src/lib/tools/agents/fileSearchTool.ts
Normal file
182
src/lib/tools/agents/fileSearchTool.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
118
src/lib/tools/agents/imageSearchTool.ts
Normal file
118
src/lib/tools/agents/imageSearchTool.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
53
src/lib/tools/agents/index.ts
Normal file
53
src/lib/tools/agents/index.ts
Normal 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
|
||||
];
|
||||
201
src/lib/tools/agents/simpleWebSearchTool.ts
Normal file
201
src/lib/tools/agents/simpleWebSearchTool.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
112
src/lib/tools/agents/taskManagerTool.ts
Normal file
112
src/lib/tools/agents/taskManagerTool.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
233
src/lib/tools/agents/urlSummarizationTool.ts
Normal file
233
src/lib/tools/agents/urlSummarizationTool.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
143
src/lib/tools/dateDifference.ts
Normal file
143
src/lib/tools/dateDifference.ts
Normal 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
13
src/lib/tools/index.ts
Normal 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];
|
||||
70
src/lib/tools/timezoneConverter.ts
Normal file
70
src/lib/tools/timezoneConverter.ts
Normal 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")',
|
||||
),
|
||||
}),
|
||||
},
|
||||
);
|
||||
34
src/lib/tracing/langfuse.ts
Normal file
34
src/lib/tracing/langfuse.ts
Normal 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
19
src/lib/types/api.ts
Normal 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
8
src/lib/types/cache.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// Cache-related types
|
||||
export interface WidgetCache {
|
||||
[widgetId: string]: {
|
||||
content: string;
|
||||
lastFetched: Date;
|
||||
expiresAt: Date;
|
||||
};
|
||||
}
|
||||
44
src/lib/types/dashboard.ts
Normal file
44
src/lib/types/dashboard.ts
Normal 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
38
src/lib/types/widget.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
213
src/lib/utils/color-utils.ts
Normal file
213
src/lib/utils/color-utils.ts
Normal 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
52
src/lib/utils/dates.ts
Normal 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}`;
|
||||
}
|
||||
|
|
@ -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
27
src/lib/utils/html.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export function encodeHtmlAttribute(value: string): string {
|
||||
if (!value) return '';
|
||||
return value
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll("'", ''');
|
||||
}
|
||||
|
||||
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('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('&', '&');
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue