diff --git a/apps/mail/actions/ai-reply.ts b/apps/mail/actions/ai-reply.ts deleted file mode 100644 index 788d2dcd1..000000000 --- a/apps/mail/actions/ai-reply.ts +++ /dev/null @@ -1,91 +0,0 @@ -'use server'; - -import { generateCompletions } from '@/lib/groq'; -import { headers } from 'next/headers'; -import { auth } from '@/lib/auth'; - -// Generates an AI response for an email reply based on the thread content -export async function generateAIResponse( - threadContent: string, - originalSender: string, -): Promise { - const headersList = await headers(); - const session = await auth.api.getSession({ headers: headersList }); - - if (!session?.user) return ''; - - if (!process.env.GROQ_API_KEY) { - throw new Error('Groq API key is not configured'); - } - - // Use a more aggressive content reduction approach - const processedContent = threadContent; - - // Create the system message - const systemPrompt = `You are an email assistant helping ${session.user.name} write professional and concise email replies. - - CRITICAL INSTRUCTIONS: - - Return ONLY the email content itself - - DO NOT include ANY explanatory text or meta-text like "Here's a draft" or "Here's a reply" - - DO NOT include ANY text before or after the email content - - Start directly with the greeting (e.g. "Hi John,") - - End with just the name - - Generate a real, ready-to-send email reply, not a template - - Do not include placeholders like [Recipient], [discount percentage], etc. - - Do not include formatting instructions or explanations - - Do not include "Subject:" lines - - Write as if this email is ready to be sent immediately - - Use real, specific content instead of placeholders - - Address the recipient directly without using [brackets] - - Be concise but thorough (2-3 paragraphs maximum) - - Write in the first person as if you are ${session.user.name} - - Double space paragraphs (2 newlines) - - Add two spaces below the sign-off - - End with the name: ${session.user.name}`; - - // Create the user message - keep it shorter - const prompt = ` - Here's the context of the email thread (some parts may be summarized or truncated due to length): - ${processedContent} - - Write a professional, helpful, and concise email reply to ${originalSender}. - Keep your response under 200 words. - - CRITICAL: Return ONLY the email content itself. DO NOT include ANY explanatory text or meta-text. - Start directly with the greeting and end with just the name. - - Important instructions: - - Return ONLY the email content itself - - DO NOT include ANY explanatory text or meta-text - - Start directly with the greeting (e.g. "Hi John,") - - End with just the name - - Generate a real, ready-to-send email reply, not a template - - Do not include placeholders like [Recipient], [discount percentage], etc. - - Do not include formatting instructions or explanations - - Do not include "Subject:" lines - - Write as if this email is ready to be sent immediately - - Use real, specific content instead of placeholders - - Address the recipient directly without using [brackets] - - Be concise but thorough (2-3 paragraphs maximum) - - Write in the first person as if you are ${session.user.name} - - Double space paragraphs (2 newlines) - - Add two spaces below the sign-off - - End with the name: ${session.user.name}`; - - try { - console.log('Generating AI response with prompt:', prompt); - // Use direct fetch to the Groq API - const { completion } = await generateCompletions({ - model: 'gpt-4o-mini', - systemPrompt: process.env.AI_SYSTEM_PROMPT || systemPrompt, - prompt, - temperature: 0.6, - userName: session.user.name, - }); - - return completion; - } catch (error: any) { - console.error('Error generating AI response:', error); - throw error; - } -} diff --git a/apps/mail/app/api/chat/prompt.ts b/apps/mail/app/api/chat/prompt.ts new file mode 100644 index 000000000..d18e071ce --- /dev/null +++ b/apps/mail/app/api/chat/prompt.ts @@ -0,0 +1,59 @@ +const prompt = `You are an intelligent email management assistant with access to powerful Gmail operations. You can help users organize their inbox by searching, analyzing, and performing actions on their emails. + +Core Capabilities: +1. Search & Analysis + - Search through email threads using complex queries + - Analyze email content, subjects, and patterns + - Identify email categories and suggested organizations + +2. Label Management + - Create new labels with custom colors + - View existing labels + - Apply labels to emails based on content analysis + - Suggest label hierarchies for better organization + +3. Email Organization + - Archive emails that don't need immediate attention + - Mark emails as read/unread strategically + - Apply bulk actions to similar emails + - Help maintain inbox zero principles + +Available Tools: +- listThreads: Search and retrieve email threads +- archiveThreads: Move emails out of inbox +- markThreadsRead/Unread: Manage read status +- createLabel: Create new organizational labels +- addLabelsToThreads: Apply labels to emails +- getUserLabels: View existing label structure + +Best Practices: +1. Always confirm actions before processing large numbers of emails +2. Suggest organizational strategies based on user's email patterns +3. Explain your reasoning when recommending actions +4. Be cautious with permanent actions like deletion +5. Consider email importance and urgency when organizing + +Examples of how you can help: +- "Find all my unread newsletter emails and help me organize them" +- "Create a systematic way to handle my recruitment emails" +- "Help me clean up my inbox by identifying and archiving non-critical emails" +- "Set up a label system for my project-related emails" + +When suggesting actions, consider: +- Email importance and time sensitivity +- Natural groupings and categories +- Workflow optimization +- Future searchability +- Maintenance requirements + +Response Format Rules: +1. NEVER include tool call results in your text response +2. NEVER start responses with phrases like "Here is", "I found", etc. +3. ONLY respond with exactly one of these two options: + - "Done." (when the action is completed successfully) + - "Could not complete action." (when the action fails or cannot be completed) + +Remember: Your goal is to help users maintain an organized, efficient, and stress-free email system while preserving important information and accessibility. +`; + +export default prompt; diff --git a/apps/mail/app/api/chat/route.ts b/apps/mail/app/api/chat/route.ts index eb6a90219..2d25c17be 100644 --- a/apps/mail/app/api/chat/route.ts +++ b/apps/mail/app/api/chat/route.ts @@ -1,10 +1,10 @@ import { getActiveConnection } from '@/actions/utils'; import { ToolInvocation, streamText } from 'ai'; -import { type IConfig } from '../driver/types'; import { NextResponse } from 'next/server'; import { createDriver } from '../driver'; import { openai } from '@ai-sdk/openai'; -import { listThreads } from './tools'; +import prompt from './prompt'; +import { z } from 'zod'; interface Message { role: 'user' | 'assistant'; @@ -20,28 +20,105 @@ export async function POST(req: Request) { console.error('Unauthorized: No valid connection found'); return NextResponse.json({}, { status: 401 }); } + const driver = await createDriver(connection.providerId, { auth: { access_token: connection.accessToken, - refresh_token: connection.refreshToken, + refresh_token: connection.refreshToken!, email: connection.email, }, }); const result = streamText({ model: openai('gpt-4o'), - system: ` - You are a helpful assistant. - You are able to list email threads. Use the listThreads tool to do so. - Parameters: - - folder: the folder to list threads from - - query: the search query - - maxResults: the maximum number of results - - labelIds: the label IDs to filter by - `, + system: prompt, messages, tools: { - listThreads: listThreads(driver), + listThreads: { + description: 'List email threads', + parameters: z.object({ + folder: z + .string() + .optional() + .default('inbox') + .describe('The folder to list threads from'), + query: z.string().optional().describe('The search query'), + maxResults: z.number().optional().default(20).describe('The maximum number of results'), + labelIds: z.array(z.string()).optional().describe('The label IDs to filter by'), + }), + execute: async ({ folder, query, maxResults, labelIds }) => { + return driver.list(folder, query, maxResults, labelIds, undefined); + }, + }, + archiveThreads: { + description: 'Archive email threads by removing them from inbox', + parameters: z.object({ + threadIds: z.array(z.string()).describe('Array of thread IDs to archive'), + }), + execute: async ({ threadIds }) => { + await driver.modifyLabels(threadIds, { + removeLabels: ['INBOX'], + addLabels: [], + }); + return { archived: threadIds.length }; + }, + }, + markThreadsRead: { + description: 'Mark email threads as read', + parameters: z.object({ + threadIds: z.array(z.string()).describe('Array of thread IDs to mark as read'), + }), + execute: async ({ threadIds }) => { + await driver.markAsRead(threadIds); + return { marked: threadIds.length }; + }, + }, + markThreadsUnread: { + description: 'Mark email threads as unread', + parameters: z.object({ + threadIds: z.array(z.string()).describe('Array of thread IDs to mark as unread'), + }), + execute: async ({ threadIds }) => { + await driver.markAsUnread(threadIds); + return { marked: threadIds.length }; + }, + }, + createLabel: { + description: 'Create a new label', + parameters: z.object({ + name: z.string().describe('Name of the label to create'), + backgroundColor: z.string().optional().describe('Background color for the label'), + textColor: z.string().optional().describe('Text color for the label'), + }), + execute: async ({ name, backgroundColor = '#e3e3e3', textColor = '#666666' }) => { + const label = await driver.createLabel({ + name, + color: { backgroundColor: '#FFFFFF', textColor: '#000000' }, + }); + return { created: label.id }; + }, + }, + addLabelsToThreads: { + description: 'Add labels to email threads', + parameters: z.object({ + threadIds: z.array(z.string()).describe('Array of thread IDs to label'), + labelIds: z.array(z.string()).describe('Array of label IDs to add'), + }), + execute: async ({ threadIds, labelIds }) => { + await driver.modifyLabels(threadIds, { + addLabels: labelIds, + removeLabels: [], + }); + return { labeled: threadIds.length }; + }, + }, + getUserLabels: { + description: 'Get all user labels', + parameters: z.object({}), + execute: async () => { + return driver.getUserLabels(); + }, + }, }, }); diff --git a/apps/mail/app/api/chat/tools.ts b/apps/mail/app/api/chat/tools.ts deleted file mode 100644 index 7c4f8c7b8..000000000 --- a/apps/mail/app/api/chat/tools.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { MailManager } from '../driver/types'; - -export const listThreads = (driver: MailManager) => ({ - description: 'List email threads', - parameters: { - type: 'object', - properties: { - folder: { type: 'string', description: 'Folder to list threads from' }, - query: { type: 'string', description: 'Search query' }, - maxResults: { type: 'number', description: 'Maximum number of results' }, - labelIds: { - type: 'array', - items: { type: 'string' }, - description: 'Label IDs to filter by', - }, - pageToken: { type: 'string', description: 'Page token for pagination' }, - }, - required: ['folder'], - }, - execute: async ({ - folder, - query, - maxResults, - labelIds, - }: { - folder: string; - query?: string; - maxResults?: number; - labelIds?: string[]; - }) => { - console.log('trying to list threads'); - - return driver.list(folder, query, maxResults, labelIds, undefined); - }, -}); diff --git a/apps/mail/components/create/ai-chat.tsx b/apps/mail/components/create/ai-chat.tsx index f998fcb8d..0fe45e748 100644 --- a/apps/mail/components/create/ai-chat.tsx +++ b/apps/mail/components/create/ai-chat.tsx @@ -1,15 +1,17 @@ 'use client'; -import { ArrowUpIcon, Mic, CheckIcon, XIcon, Plus, Command } from 'lucide-react'; +import { ArrowUpIcon, Mic, CheckIcon, XIcon, Plus, Command, ArrowDownCircle } from 'lucide-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { useSearchValue } from '@/hooks/use-search-value'; import { useConnections } from '@/hooks/use-connections'; import { useRef, useCallback, useEffect } from 'react'; import { Button } from '@/components/ui/button'; +import { useThread } from '@/hooks/use-threads'; import { useSession } from '@/lib/auth-client'; import { CurvedArrow } from '../icons/icons'; import { AITextarea } from './ai-textarea'; import { useChat } from '@ai-sdk/react'; +import { useQueryState } from 'nuqs'; import { cn } from '@/lib/utils'; import { useState } from 'react'; import VoiceChat from './voice'; @@ -45,6 +47,29 @@ interface AIChatProps { onReset?: () => void; } +const renderThread = (thread: { id: string; title: string; snippet: string }) => { + const [, setThreadId] = useQueryState('threadId'); + const { data: getThread } = useThread(thread.id); + return getThread ? ( +
setThreadId(thread.id)} + key={thread.id} + className="bg-subtleBlack cursor-pointer rounded-md border p-2 hover:bg-black" + > +

{getThread.latest?.subject}

+
+ ) : null; +}; + +const RenderThreads = ({ + threads, +}: { + threads: { id: string; title: string; snippet: string }[]; +}) => { + const [, setThreadId] = useQueryState('threadId'); + return threads.map(renderThread); +}; + export function AIChat({ editor, onMessagesChange, onReset }: AIChatProps) { const [value, setValue] = useState(''); const [showVoiceChat, setShowVoiceChat] = useState(false); @@ -59,7 +84,7 @@ export function AIChat({ editor, onMessagesChange, onReset }: AIChatProps) { const { data: session } = useSession(); const { data: connections } = useConnections(); - const { messages, input, setInput, append, handleSubmit, status } = useChat({ + const { messages, input, setInput, error, handleSubmit, status } = useChat({ api: '/api/chat', maxSteps: 5, }); @@ -274,9 +299,35 @@ export function AIChat({ editor, onMessagesChange, onReset }: AIChatProps) { : 'overflow-wrap-anywhere mr-auto break-words bg-[#f0f0f0] p-3 dark:bg-[#313131]', // Assistant messages aligned to left )} > -
+ {/*
{message.content} -
+
*/} + + {message.parts.map((part) => { + if (part.type === 'text') { + return

{part.text}

; + } + if (part.type === 'reasoning') { + return

Reasoning: {part.reasoning}

; + } + if (part.type === 'tool-invocation') { + return ( + 'result' in part.toolInvocation && + ('threads' in part.toolInvocation.result ? ( + + ) : ( +

No threads found

+ )) + ); + } + if (part.type === 'source') { + return

Source: {part.source.title}

; + } + // if (part.type === 'step-start') { + // return ; + // } + // return

{part.type}

; + })} {/* {message.type === 'search' && message.searchContent && @@ -362,6 +413,7 @@ export function AIChat({ editor, onMessagesChange, onReset }: AIChatProps) { {/* Invisible element to scroll to */}
+ {JSON.stringify(error)} {/* Loading indicator */} {status === 'submitted' && (
diff --git a/apps/mail/lib/providers.tsx b/apps/mail/lib/providers.tsx index 426b6f777..0dc9f69b9 100644 --- a/apps/mail/lib/providers.tsx +++ b/apps/mail/lib/providers.tsx @@ -15,16 +15,16 @@ export function Providers({ children, ...props }: React.ComponentProps - - + + + {children} - - - + + + ); }