diff --git a/.cursor/rules/get-api-route.mdc b/.cursor/rules/get-api-route.mdc index 3c34a27a77..d61bde4755 100644 --- a/.cursor/rules/get-api-route.mdc +++ b/.cursor/rules/get-api-route.mdc @@ -14,12 +14,13 @@ import { NextResponse } from "next/server"; import prisma from "@/utils/prisma"; import { withAuth } from "@/utils/middleware"; +// Notice how we infer the response type. We don't need to manually type it out export type GetExampleResponse = Awaited>; -export const GET = withAuth(async () => { +// The middleware does the error handling and authentication for us already +export const GET = withEmailAccount(async () => { const emailAccountId = request.auth.emailAccountId; - const result = getData({ email }); return NextResponse.json(result); }); @@ -37,8 +38,9 @@ See [data-fetching.mdc](mdc:.cursor/rules/data-fetching.mdc) as to how this woul Key Requirements: - - Always wrap the handler with `withAuth` for consistent error handling and authentication. - - We don't need try/catch as `withAuth` handles that. - - Infer and export response type. + - Always wrap the handler with `withAuth` or `withEmailAccount` for consistent error handling and authentication. + - `withAuth` gets the user. `withEmailAccount` gets the currently active email account. A user can have multiple email accounts under it. + - We don't need try/catch as `withAuth` and `withEmailAccount` handles that. + - Infer and export response type as in the example. - Use Prisma for database queries. - Return responses using `NextResponse.json()` diff --git a/.cursor/rules/memory.mdc b/.cursor/rules/memory.mdc deleted file mode 100644 index 0e6f72ee9c..0000000000 --- a/.cursor/rules/memory.mdc +++ /dev/null @@ -1,69 +0,0 @@ ---- -description: -globs: -alwaysApply: true ---- -# AI Memory Rule - -This rule defines how the AI should manage and utilize its "memory" regarding this specific project, including user preferences, learned facts, and project-specific conventions. - -## Purpose - -The AI's memory helps maintain consistency and adapt to specific project needs or user preferences discovered during interactions. It prevents the AI from repeatedly asking for the same information or making suggestions contrary to established patterns. - -## Storage - -All learned project-specific knowledge and preferences should be stored and referenced in the `learned-memories.mdc` file located in `.cursor/rules`. - -## Updating Memory - -When new information relevant to the project's conventions, user preferences, or specific technical details is learned (either explicitly told by the user or inferred through conversation), the AI should: - -1. **Identify Key Information:** Determine the core piece of knowledge to be stored. -2. **Check Existing Memory:** Review `learned-memories.mdc` to see if this information contradicts or updates existing entries. -3. **Propose Update:** Suggest an edit to `learned-memories.mdc` to add or modify the relevant information. Keep entries concise and clear. - -## Using Memory - -Before proposing solutions, code changes, or answering questions, the AI should consult `learned-memories.mdc` to ensure its response aligns with the recorded knowledge and preferences. - -## Example Scenario - -**User:** "We've decided to use Tailwind v4 for this project, not v3." - -**AI Action:** - -1. Recognize this as a project-specific technical decision. -2. Check `learned-memories.mdc` for existing Tailwind version information. -3. Propose adding or updating an entry in `learned-memories.mdc`: - ```markdown - ## Technical Decisions - - * **CSS Framework:** Tailwind v4 is used. Ensure usage aligns with v4 documentation and practices, noting differences from v3. - ``` -4. In subsequent interactions involving Tailwind, the AI will refer to this entry and consult v4 documentation if necessary. - -## Memory File (`.cursor/rules/learned-memories.mdc`) - -The basic structure: - -```markdown -# Project Memory - -This file stores project-specific knowledge, conventions, and user preferences learned by the AI assistant. - -## User Preferences - -- [Preference 1] -- [Preference 2] - -## Technical Decisions - -- [Decision 1] -- [Decision 2] - -## Project Conventions - -- [Convention 1] -- [Convention 2] -``` diff --git a/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx b/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx index 61c3482d71..e639d62e87 100644 --- a/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/assistant/page.tsx @@ -33,22 +33,13 @@ export default async function AssistantPage({ } } - // const hasPendingRule = prisma.rule.findFirst({ - // where: { emailAccountId, automate: false }, - // select: { id: true }, - // }); - return (
- +
diff --git a/apps/web/app/(app)/[emailAccountId]/automation/AssistantTabs.tsx b/apps/web/app/(app)/[emailAccountId]/automation/AssistantTabs.tsx index fc1dbe6006..468e922ecc 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/AssistantTabs.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/AssistantTabs.tsx @@ -1,3 +1,6 @@ +"use client"; + +import useSWR from "swr"; import { History } from "@/app/(app)/[emailAccountId]/automation/History"; import { Pending } from "@/app/(app)/[emailAccountId]/automation/Pending"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -8,8 +11,14 @@ import { RulesPrompt } from "@/app/(app)/[emailAccountId]/automation/RulesPrompt import { TabsToolbar } from "@/components/TabsToolbar"; import { TypographyP } from "@/components/Typography"; import { RuleTab } from "@/app/(app)/[emailAccountId]/automation/RuleTab"; +import type { GetPendingRulesResponse } from "@/app/api/rules/pending/route"; export function AssistantTabs() { + const { data: pendingData } = + useSWR("/api/rules/pending"); + + const hasPendingRule = pendingData?.hasPending ?? false; + return (
@@ -20,11 +29,9 @@ export function AssistantTabs() { Rules Test History - {/* - {(await hasPendingRule) && ( - Pending - )} - */} + {hasPendingRule && ( + Pending + )} Knowledge Base
@@ -56,9 +63,9 @@ export function AssistantTabs() {
- + Select a tab or chat with your AI assistant to explain how it - should handle your incoming emails + should handle incoming emails
@@ -75,12 +82,15 @@ export function AssistantTabs() { - - - + {hasPendingRule && ( + + + + )} + {/* Set via search params. Not a visible tab. */} diff --git a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx index 7e53c1574b..754b54e6c7 100644 --- a/apps/web/app/(app)/[emailAccountId]/automation/page.tsx +++ b/apps/web/app/(app)/[emailAccountId]/automation/page.tsx @@ -1,5 +1,4 @@ import { Suspense } from "react"; -import Link from "next/link"; import { cookies } from "next/headers"; import { redirect } from "next/navigation"; import prisma from "@/utils/prisma"; @@ -15,7 +14,6 @@ import { PermissionsCheck } from "@/app/(app)/[emailAccountId]/PermissionsCheck" import { TabsToolbar } from "@/components/TabsToolbar"; import { GmailProvider } from "@/providers/GmailProvider"; import { ASSISTANT_ONBOARDING_COOKIE } from "@/utils/cookies"; -import { Button } from "@/components/ui/button"; import { prefixPath } from "@/utils/path"; export const maxDuration = 300; // Applies to the actions diff --git a/apps/web/app/api/ai/summarise/controller.ts b/apps/web/app/api/ai/summarise/controller.ts index c05a8aaf32..0fd811b024 100644 --- a/apps/web/app/api/ai/summarise/controller.ts +++ b/apps/web/app/api/ai/summarise/controller.ts @@ -24,8 +24,8 @@ export async function summarise({ prompt, userEmail, usageLabel: "Summarise", - onFinish: async (completion) => { - await saveSummary(prompt, completion); + onFinish: async (result) => { + await saveSummary(prompt, result.text); await expire(prompt, 60 * 60 * 24); }, }); diff --git a/apps/web/app/api/chat/route.ts b/apps/web/app/api/chat/route.ts index 67201f1427..2cc37124ab 100644 --- a/apps/web/app/api/chat/route.ts +++ b/apps/web/app/api/chat/route.ts @@ -1,18 +1,45 @@ +import { + appendClientMessage, + appendResponseMessages, + type CoreAssistantMessage, + type CoreToolMessage, +} from "ai"; import { z } from "zod"; import { withEmailAccount } from "@/utils/middleware"; import { getEmailAccountWithAi } from "@/utils/user/get"; import { NextResponse } from "next/server"; import { aiProcessAssistantChat } from "@/utils/ai/assistant/chat"; +import { createScopedLogger } from "@/utils/logger"; +import prisma from "@/utils/prisma"; +import { Prisma, type ChatMessage } from "@prisma/client"; export const maxDuration = 120; +const logger = createScopedLogger("api/chat"); + +const textPartSchema = z.object({ + text: z.string().min(1).max(2000), + type: z.enum(["text"]), +}); + const assistantInputSchema = z.object({ - messages: z.array( - z.object({ - role: z.enum(["user", "assistant"]), - content: z.string(), - }), - ), + id: z.string().optional(), + message: z.object({ + id: z.string(), + createdAt: z.coerce.date(), + role: z.enum(["user"]), + content: z.string().min(1).max(2000), + parts: z.array(textPartSchema), + // experimental_attachments: z + // .array( + // z.object({ + // url: z.string().url(), + // name: z.string().min(1).max(2000), + // contentType: z.enum(["image/png", "image/jpg", "image/jpeg"]), + // }), + // ) + // .optional(), + }), }); export const POST = withEmailAccount(async (request) => { @@ -23,17 +50,141 @@ export const POST = withEmailAccount(async (request) => { if (!user) return NextResponse.json({ error: "Not authenticated" }); const json = await request.json(); - const body = assistantInputSchema.safeParse(json); + const { data, error } = assistantInputSchema.safeParse(json); + + if (error) return NextResponse.json({ error: error.errors }, { status: 400 }); + + const chat = data.id + ? await getChatById(data.id) + : await createNewChat(emailAccountId); + + if (!chat) { + return NextResponse.json( + { error: "Failed to get or create chat" }, + { status: 500 }, + ); + } - if (body.error) { - return NextResponse.json({ error: body.error.message }, { status: 400 }); + if (chat.emailAccountId !== emailAccountId) { + return NextResponse.json( + { error: "You are not authorized to access this chat" }, + { status: 403 }, + ); } + const { message } = data; + const mappedDbMessages = chat.messages.map((dbMsg: ChatMessage) => { + return { + ...dbMsg, + role: convertDbRoleToSdkRole(dbMsg.role), + content: "", + parts: dbMsg.parts as any, + }; + }); + + const messages = appendClientMessage({ + messages: mappedDbMessages, + message, + }); + + await saveChatMessage({ + chat: { connect: { id: chat.id } }, + id: message.id, + role: "user", + parts: message.parts, + // attachments: message.experimental_attachments ?? [], + }); + const result = await aiProcessAssistantChat({ - messages: body.data.messages, + messages, emailAccountId, user, + onFinish: async ({ response }) => { + const assistantId = getTrailingMessageId({ + messages: response.messages.filter( + (message: { role: string }) => message.role === "assistant", + ), + }); + + if (!assistantId) { + throw new Error("No assistant message found!"); + } + + // handles all tool calls + const [, assistantMessage] = appendResponseMessages({ + messages: [message], + responseMessages: response.messages, + }); + + await saveChatMessage({ + id: assistantId, + chat: { connect: { id: chat.id } }, + role: assistantMessage.role, + parts: assistantMessage.parts + ? (assistantMessage.parts as Prisma.InputJsonValue) + : Prisma.JsonNull, + // attachments: assistantMessage.experimental_attachments ?? [], + }); + }, }); return result.toDataStreamResponse(); }); + +async function createNewChat(emailAccountId: string) { + try { + const newChat = await prisma.chat.create({ + data: { emailAccountId }, + include: { messages: true }, + }); + logger.info("New chat created", { chatId: newChat.id, emailAccountId }); + return newChat; + } catch (error) { + logger.error("Failed to create new chat", { error, emailAccountId }); + return undefined; + } +} + +async function saveChatMessage(message: Prisma.ChatMessageCreateInput) { + return prisma.chatMessage.create({ data: message }); +} + +async function getChatById(chatId: string) { + const chat = await prisma.chat.findUnique({ + where: { id: chatId }, + include: { messages: true }, + }); + return chat; +} + +function convertDbRoleToSdkRole( + role: string, +): "user" | "assistant" | "system" | "data" { + switch (role) { + case "user": + return "user"; + case "assistant": + return "assistant"; + case "system": + return "system"; + case "data": + return "data"; + default: + return "assistant"; + } +} + +type ResponseMessageWithoutId = CoreToolMessage | CoreAssistantMessage; +type ResponseMessage = ResponseMessageWithoutId & { id: string }; + +function getTrailingMessageId({ + messages, +}: { + messages: Array; +}): string | null { + const trailingMessage = messages.at(-1); + + if (!trailingMessage) return null; + + return trailingMessage.id; +} diff --git a/apps/web/app/api/chats/[chatId]/route.ts b/apps/web/app/api/chats/[chatId]/route.ts new file mode 100644 index 0000000000..3b79690a6b --- /dev/null +++ b/apps/web/app/api/chats/[chatId]/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; + +export type GetChatResponse = Awaited>; + +export const GET = withEmailAccount(async (request, { params }) => { + const { emailAccountId } = request.auth; + const { chatId } = await params; + + if (!chatId) { + return NextResponse.json( + { error: "Chat ID is required." }, + { status: 400 }, + ); + } + + const chat = await getChat({ chatId, emailAccountId }); + + if (!chat) { + return NextResponse.json( + { error: "Chat not found or access denied." }, + { status: 404 }, + ); + } + return NextResponse.json(chat); +}); + +async function getChat({ + chatId, + emailAccountId, +}: { + chatId: string; + emailAccountId: string; +}) { + const chat = await prisma.chat.findUnique({ + where: { + id: chatId, + emailAccountId, + }, + include: { + messages: { + orderBy: { + createdAt: "asc", + }, + }, + }, + }); + + return chat; +} diff --git a/apps/web/app/api/chats/route.ts b/apps/web/app/api/chats/route.ts new file mode 100644 index 0000000000..451ee0e713 --- /dev/null +++ b/apps/web/app/api/chats/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; + +export type GetChatsResponse = Awaited>; + +export const GET = withEmailAccount(async (request) => { + const emailAccountId = request.auth.emailAccountId; + const result = await getChats({ emailAccountId }); + return NextResponse.json(result); +}); + +async function getChats({ emailAccountId }: { emailAccountId: string }) { + const chats = await prisma.chat.findMany({ + where: { emailAccountId }, + orderBy: { updatedAt: "desc" }, + }); + + return { chats }; +} diff --git a/apps/web/app/api/rules/pending/route.ts b/apps/web/app/api/rules/pending/route.ts new file mode 100644 index 0000000000..5a7266a5be --- /dev/null +++ b/apps/web/app/api/rules/pending/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; +import prisma from "@/utils/prisma"; +import { withEmailAccount } from "@/utils/middleware"; +import type { RequestWithEmailAccount } from "@/utils/middleware"; + +export type GetPendingRulesResponse = Awaited< + ReturnType +>; + +export const GET = withEmailAccount(async (req: RequestWithEmailAccount) => { + const { emailAccountId } = req.auth; + const data = await getPendingRules({ emailAccountId }); + return NextResponse.json(data); +}); + +async function getPendingRules({ emailAccountId }: { emailAccountId: string }) { + const rule = await prisma.rule.findFirst({ + where: { emailAccountId, automate: false }, + select: { id: true }, + }); + + return { hasPending: Boolean(rule) }; +} diff --git a/apps/web/components/assistant-chat/chat.tsx b/apps/web/components/assistant-chat/chat.tsx index 5e1d2881cd..8955d16698 100644 --- a/apps/web/components/assistant-chat/chat.tsx +++ b/apps/web/components/assistant-chat/chat.tsx @@ -1,10 +1,13 @@ "use client"; import type React from "react"; +import { useState } from "react"; import { type ScopedMutator, SWRConfig, useSWRConfig } from "swr"; import type { UIMessage } from "ai"; import { useChat } from "@ai-sdk/react"; import { toast } from "sonner"; +import { HistoryIcon, Loader2 } from "lucide-react"; +import { useQueryState } from "nuqs"; import { MultimodalInput } from "@/components/assistant-chat/multimodal-input"; import { Messages } from "./messages"; import { EMAIL_ACCOUNT_HEADER } from "@/utils/config"; @@ -14,6 +17,17 @@ import { ResizablePanel } from "@/components/ui/resizable"; import { AssistantTabs } from "@/app/(app)/[emailAccountId]/automation/AssistantTabs"; import { ChatProvider } from "./ChatContext"; import { SWRProvider } from "@/providers/SWRProvider"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { useChats } from "@/hooks/useChats"; +import { LoadingContent } from "@/components/LoadingContent"; +import { useChatMessages } from "@/hooks/useChatMessages"; +import type { GetChatResponse } from "@/app/api/chats/[chatId]/route"; // Some mega hacky code used here to workaround AI SDK's use of SWR // AI SDK uses SWR too and this messes with the global SWR config @@ -23,8 +37,6 @@ import { SWRProvider } from "@/providers/SWRProvider"; // AI SDK v5 won't use SWR anymore so we can remove this workaround type ChatProps = { - id: string; - initialMessages: Array; emailAccountId: string; }; @@ -32,28 +44,42 @@ export function Chat(props: ChatProps) { // Use parent SWR config for mutate const { mutate } = useSWRConfig(); + const [chatId] = useQueryState("chatId"); + const { data } = useChatMessages(chatId ?? undefined); + return ( - + ); } function ChatInner({ - id, + chatId, initialMessages, emailAccountId, mutate, }: ChatProps & { + chatId?: string; mutate: ScopedMutator; + initialMessages: Array; }) { const chat = useChat({ - id, - body: { id }, + id: chatId, + api: "/api/chat", + experimental_prepareRequestBody: (body) => ({ + id: chatId, + message: body.messages.at(-1), + }), initialMessages, experimental_throttle: 100, sendExtraMessageFields: true, @@ -66,7 +92,7 @@ function ChatInner({ }, onError: (error) => { console.error(error); - toast.error("An error occured, please try again!"); + toast.error(`An error occured! ${error.message || ""}`); }, }); @@ -74,7 +100,7 @@ function ChatInner({ - + @@ -93,7 +119,7 @@ function ChatUI({ chatId, }: { chat: ReturnType; - chatId: string; + chatId?: string; }) { const { messages, @@ -108,6 +134,11 @@ function ChatUI({ return (
+
+ + + +
+ + + + + + + Loading chats... + + } + errorComponent={ + Error loading chats + } + > + {data && data.chats.length > 0 ? ( + data.chats.map((chatItem) => ( + { + setChatId(chatItem.id); + }} + > + {`Chat from ${new Date(chatItem.createdAt).toLocaleString()}`} + + )) + ) : ( + + No previous chats found + + )} + + + + ); +} + // NOTE: not sure why we don't just use the default from AI SDK function generateUUID(): string { return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => { @@ -159,3 +247,17 @@ function generateUUID(): string { return v.toString(16); }); } + +function convertToUIMessages(chat: GetChatResponse): Array { + return ( + chat?.messages.map((message) => ({ + id: message.id, + parts: message.parts as UIMessage["parts"], + role: message.role as UIMessage["role"], + // Note: content will soon be deprecated in @ai-sdk/react + content: "", + createdAt: message.createdAt, + // experimental_attachments: (message.attachments as Array) ?? [], + })) || [] + ); +} diff --git a/apps/web/components/assistant-chat/multimodal-input.tsx b/apps/web/components/assistant-chat/multimodal-input.tsx index ba67f278f9..2cdba96436 100644 --- a/apps/web/components/assistant-chat/multimodal-input.tsx +++ b/apps/web/components/assistant-chat/multimodal-input.tsx @@ -33,7 +33,7 @@ function PureMultimodalInput({ handleSubmit, className, }: { - chatId: string; + chatId?: string; input: UseChatHelpers["input"]; setInput: UseChatHelpers["setInput"]; status: UseChatHelpers["status"]; @@ -107,7 +107,7 @@ function PureMultimodalInput({ if (width && width > 768) { textareaRef.current?.focus(); } - }, [handleSubmit, setLocalStorageInput, width, chatId]); + }, [handleSubmit, setLocalStorageInput, width]); return (
diff --git a/apps/web/hooks/useChatMessages.ts b/apps/web/hooks/useChatMessages.ts new file mode 100644 index 0000000000..d82f9adc8a --- /dev/null +++ b/apps/web/hooks/useChatMessages.ts @@ -0,0 +1,6 @@ +import useSWR from "swr"; +import type { GetChatResponse } from "@/app/api/chats/[chatId]/route"; + +export function useChatMessages(chatId?: string) { + return useSWR(chatId ? `/api/chats/${chatId}` : null); +} diff --git a/apps/web/hooks/useChats.ts b/apps/web/hooks/useChats.ts new file mode 100644 index 0000000000..8d8a549f06 --- /dev/null +++ b/apps/web/hooks/useChats.ts @@ -0,0 +1,6 @@ +import useSWR from "swr"; +import type { GetChatsResponse } from "@/app/api/chats/route"; + +export function useChats(shouldFetch: boolean) { + return useSWR(shouldFetch ? "/api/chats" : null); +} diff --git a/apps/web/prisma/migrations/20250521104911_chat/migration.sql b/apps/web/prisma/migrations/20250521104911_chat/migration.sql new file mode 100644 index 0000000000..c2273db331 --- /dev/null +++ b/apps/web/prisma/migrations/20250521104911_chat/migration.sql @@ -0,0 +1,33 @@ +-- CreateTable +CREATE TABLE "Chat" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "emailAccountId" TEXT NOT NULL, + + CONSTRAINT "Chat_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "ChatMessage" ( + "id" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + "role" TEXT NOT NULL, + "content" TEXT NOT NULL, + "chatId" TEXT NOT NULL, + + CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Chat_emailAccountId_idx" ON "Chat"("emailAccountId"); + +-- CreateIndex +CREATE INDEX "ChatMessage_chatId_idx" ON "ChatMessage"("chatId"); + +-- AddForeignKey +ALTER TABLE "Chat" ADD CONSTRAINT "Chat_emailAccountId_fkey" FOREIGN KEY ("emailAccountId") REFERENCES "EmailAccount"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_chatId_fkey" FOREIGN KEY ("chatId") REFERENCES "Chat"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/web/prisma/migrations/20250521132820_message_parts/migration.sql b/apps/web/prisma/migrations/20250521132820_message_parts/migration.sql new file mode 100644 index 0000000000..39562844b5 --- /dev/null +++ b/apps/web/prisma/migrations/20250521132820_message_parts/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `content` on the `ChatMessage` table. All the data in the column will be lost. + - Added the required column `parts` to the `ChatMessage` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "ChatMessage" DROP COLUMN "content", +ADD COLUMN "parts" JSONB NOT NULL; diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma index cc4db7bbb0..dc9c4264d0 100644 --- a/apps/web/prisma/schema.prisma +++ b/apps/web/prisma/schema.prisma @@ -52,18 +52,18 @@ model User { sessions Session[] // additional fields - completedOnboardingAt DateTime? // questions about the user. e.g. their role - completedAppOnboardingAt DateTime? // how to use the app - onboardingAnswers Json? - lastLogin DateTime? - utms Json? - errorMessages Json? // eg. user set incorrect AI API key + completedOnboardingAt DateTime? // questions about the user. e.g. their role + completedAppOnboardingAt DateTime? // how to use the app + onboardingAnswers Json? + lastLogin DateTime? + utms Json? + errorMessages Json? // eg. user set incorrect AI API key // settings - aiProvider String? - aiModel String? - aiApiKey String? - webhookSecret String? + aiProvider String? + aiModel String? + aiApiKey String? + webhookSecret String? // premium can be shared among multiple users premiumId String? @@ -121,6 +121,7 @@ model EmailAccount { emailMessages EmailMessage[] emailTokens EmailToken[] knowledge Knowledge[] + chats Chat[] @@index([lastSummaryEmailAt]) } @@ -565,6 +566,33 @@ model DraftSendLog { @@index([executedActionId]) } +model Chat { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + messages ChatMessage[] + emailAccountId String + emailAccount EmailAccount @relation(fields: [emailAccountId], references: [id], onDelete: Cascade) + + @@index([emailAccountId]) +} + +model ChatMessage { + id String @id @default(cuid()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + role String + parts Json + // attachments Json? + + chatId String + chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade) + + @@index([chatId]) +} + enum ActionType { ARCHIVE LABEL diff --git a/apps/web/utils/ai/assistant/chat.ts b/apps/web/utils/ai/assistant/chat.ts index e4021f98b7..4c27edd1ff 100644 --- a/apps/web/utils/ai/assistant/chat.ts +++ b/apps/web/utils/ai/assistant/chat.ts @@ -1,4 +1,4 @@ -import { tool } from "ai"; +import { type Message, type StepResult, type Tool, tool } from "ai"; import { z } from "zod"; import { createScopedLogger } from "@/utils/logger"; import { createRuleSchema } from "@/utils/ai/rule/create-rule-schema"; @@ -95,59 +95,6 @@ export type UpdateLearnedPatternsSchema = z.infer< typeof updateLearnedPatternsSchema >; -// // Keeping the original schema for backward compatibility -// const updateRuleSchema = z.object({ -// ruleName: z.string().describe("The name of the rule to update"), -// condition: z -// .object({ -// aiInstructions: z.string(), -// static: z.object({ -// from: z.string(), -// to: z.string(), -// subject: z.string(), -// body: z.string(), -// }), -// conditionalOperator: z.enum([LogicalOperator.AND, LogicalOperator.OR]), -// }) -// .optional(), -// actions: z.array( -// z -// .object({ -// type: z.enum([ -// ActionType.ARCHIVE, -// ActionType.LABEL, -// ActionType.REPLY, -// ActionType.SEND_EMAIL, -// ActionType.FORWARD, -// ActionType.MARK_READ, -// ActionType.MARK_SPAM, -// ActionType.CALL_WEBHOOK, -// ]), -// fields: z.object({ -// label: z.string().optional(), -// content: z.string().optional(), -// webhookUrl: z.string().optional(), -// }), -// }) -// .optional(), -// ), -// learnedPatterns: z -// .array( -// z.object({ -// include: z.object({ -// from: z.string(), -// subject: z.string(), -// }), -// exclude: z.object({ -// from: z.string(), -// subject: z.string(), -// }), -// }), -// ) -// .optional(), -// }); -// export type UpdateRuleSchema = z.infer; - const updateAboutSchema = z.object({ about: z.string() }); export type UpdateAboutSchema = z.infer; @@ -161,10 +108,17 @@ export async function aiProcessAssistantChat({ messages, emailAccountId, user, + onFinish, }: { - messages: { role: "user" | "assistant"; content: string }[]; + messages: Message[]; emailAccountId: string; user: EmailAccountWithAI; + onFinish: ( + response: Omit< + StepResult>, + "stepType" | "isContinued" + >, + ) => Promise; }) { const system = `You are an assistant that helps create and update rules to manage a user's inbox. Our platform is called Inbox Zero. @@ -431,6 +385,7 @@ Examples: onStepFinish: async ({ text, toolCalls }) => { logger.trace("Step finished", { text, toolCalls }); }, + onFinish, maxSteps: 10, tools: { get_user_rules_and_settings: tool({ diff --git a/apps/web/utils/llms/index.ts b/apps/web/utils/llms/index.ts index 1188163d1d..e52c306032 100644 --- a/apps/web/utils/llms/index.ts +++ b/apps/web/utils/llms/index.ts @@ -9,6 +9,8 @@ import { RetryError, streamText, type StepResult, + smoothStream, + type Message, } from "ai"; import { env } from "@/env"; import { saveAiUsage } from "@/utils/usage"; @@ -164,12 +166,14 @@ export async function chatCompletionStream({ useEconomyModel?: boolean; system?: string; prompt?: string; - messages?: CoreMessage[]; + messages?: Message[]; tools?: Record; maxSteps?: number; userEmail: string; usageLabel: string; - onFinish?: (text: string) => Promise; + onFinish?: ( + result: Omit>, "stepType" | "isContinued">, + ) => Promise; onStepFinish?: ( stepResult: StepResult>, ) => Promise; @@ -188,17 +192,18 @@ export async function chatCompletionStream({ maxSteps, providerOptions, ...commonOptions, + experimental_transform: smoothStream({ chunking: "word" }), onStepFinish, - onFinish: async ({ usage, text }) => { + onFinish: async (result) => { await saveAiUsage({ email: userEmail, provider, model, - usage, + usage: result.usage, label, }); - if (onFinish) await onFinish(text); + if (onFinish) await onFinish(result); }, });