feat(ee): add agent chat

This commit is contained in:
Marc Seitz
2025-12-08 17:23:07 +01:00
parent ad502eb732
commit 889056b321
76 changed files with 11322 additions and 1154 deletions
+1 -1
View File
@@ -2,7 +2,7 @@ Copyright (c) 2023-present Papermark, Inc.
Portions of this software are licensed as follows:
- All content that resides under https://github.com/mfts/papermark/tree/main/ee directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
- All content that resides under https://github.com/mfts/papermark/tree/main/ee and https://github.com/mfts/papermark/tree/main/app/(ee) directory of this repository (Commercial License) is licensed under the license defined in "ee/LICENSE".
- All third party components incorporated into the Papermark Software are licensed under the original license provided by the owner of the applicable component.
- Content outside of the above mentioned directories or restrictions above is available under the "AGPLv3" license as defined below.
+1
View File
@@ -0,0 +1 @@
../ee/LICENSE.md
+1
View File
@@ -0,0 +1 @@
../ee/README.md
@@ -0,0 +1,137 @@
import { NextRequest } from "next/server";
import { generateChatTitle } from "@/ee/features/ai/lib/chat/generate-chat-title";
import { getFilteredDataroomDocumentIds } from "@/ee/features/ai/lib/chat/get-filtered-dataroom-document-ids";
import { sendMessage } from "@/ee/features/ai/lib/chat/send-message";
import { validateChatAccess } from "@/ee/features/ai/lib/permissions/validate-chat-access";
import { sendMessageSchema } from "@/ee/features/ai/schemas/chat";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/chat/[chatId]/messages
* Send a message and get streaming response
*/
export async function POST(
req: NextRequest,
{ params }: { params: { chatId: string } },
) {
try {
const { chatId } = params;
const body = await req.json();
const validation = sendMessageSchema.safeParse(body);
if (!validation.success) {
return new Response(
JSON.stringify({
error: "Invalid request body",
details: validation.error,
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
const { content, filterDocumentId } = validation.data;
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
const viewerId = searchParams.get("viewerId");
let userId: string | undefined;
if (session) {
userId = (session.user as CustomUser).id;
}
// Validate access
const hasAccess = await validateChatAccess({
chatId,
userId,
viewerId: viewerId || undefined,
});
if (!hasAccess) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "Content-Type": "application/json" },
});
}
// Get chat details
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
messages: {
take: 1,
},
},
});
if (!chat) {
return new Response(JSON.stringify({ error: "Chat not found" }), {
status: 404,
headers: { "Content-Type": "application/json" },
});
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: chat.teamId });
if (!features.ai) {
return new Response(
JSON.stringify({
error: "AI features are not available for this team",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
if (!chat.vectorStoreId) {
return new Response(
JSON.stringify({
error: "Vector store not available. Please index documents first.",
}),
{ status: 400, headers: { "Content-Type": "application/json" } },
);
}
// Generate title from first message if not set
if (!chat.title && chat.messages.length === 0) {
const title = await generateChatTitle(content);
await prisma.chat.update({
where: { id: chatId },
data: { title },
});
}
// Get filtered dataroom document IDs based on permissions
let filteredDataroomDocumentIds: string[] | undefined;
if (chat.dataroomId && chat.linkId) {
filteredDataroomDocumentIds = await getFilteredDataroomDocumentIds(
chat.dataroomId,
chat.linkId,
);
}
// Send message and get streaming response
const result = await sendMessage({
chatId,
content,
vectorStoreId: chat.vectorStoreId,
filteredDataroomDocumentIds,
filterDocumentId,
});
return result.toTextStreamResponse();
} catch (error) {
console.error("Error sending message:", error);
return new Response(JSON.stringify({ error: "Internal server error" }), {
status: 500,
headers: { "Content-Type": "application/json" },
});
}
}
+146
View File
@@ -0,0 +1,146 @@
import { NextRequest, NextResponse } from "next/server";
import { validateChatAccess } from "@/ee/features/ai/lib/permissions/validate-chat-access";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* GET /api/ai/chat/[chatId]
* Get chat details with messages
*/
export async function GET(
req: NextRequest,
{ params }: { params: { chatId: string } },
) {
try {
const { chatId } = params;
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
const viewerId = searchParams.get("viewerId");
let userId: string | undefined;
if (session) {
userId = (session.user as CustomUser).id;
}
// Validate access
const hasAccess = await validateChatAccess({
chatId,
userId,
viewerId: viewerId || undefined,
});
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Fetch chat with messages
const chat = await prisma.chat.findUnique({
where: { id: chatId },
include: {
messages: {
orderBy: { createdAt: "asc" },
},
document: {
select: {
id: true,
name: true,
},
},
dataroom: {
select: {
id: true,
name: true,
},
},
},
});
if (!chat) {
return NextResponse.json({ error: "Chat not found" }, { status: 404 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: chat.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
return NextResponse.json(chat);
} catch (error) {
console.error("Error fetching chat:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* DELETE /api/ai/chat/[chatId]
* Delete a chat
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { chatId: string } },
) {
try {
const { chatId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Validate ownership
const hasAccess = await validateChatAccess({
chatId,
userId,
});
if (!hasAccess) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Verify user owns this chat
const chat = await prisma.chat.findUnique({
where: { id: chatId },
});
if (!chat || chat.userId !== userId) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: chat.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Delete chat (messages will cascade)
await prisma.chat.delete({
where: { id: chatId },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error deleting chat:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+590
View File
@@ -0,0 +1,590 @@
import { NextRequest, NextResponse } from "next/server";
import { createChat } from "@/ee/features/ai/lib/chat/create-chat";
import { createChatSchema } from "@/ee/features/ai/schemas/chat";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { z } from "zod";
import { verifyDataroomSession } from "@/lib/auth/dataroom-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/chat
* Create a new chat session
*/
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const validation = createChatSchema.safeParse(body);
if (!validation.success) {
return NextResponse.json(
{ error: "Invalid request body", details: validation.error },
{ status: 400 },
);
}
const { documentId, dataroomId, linkId, viewId, title, viewerId } =
validation.data;
// Check for either internal user or external viewer authentication
const session = await getServerSession(authOptions);
let userId: string | undefined;
let teamId: string;
// Internal user flow
if (session) {
userId = (session.user as CustomUser).id;
// Get team ID from document or dataroom
if (dataroomId) {
const dataroom = await prisma.dataroom.findUnique({
where: { id: dataroomId },
select: {
teamId: true,
agentsEnabled: true,
vectorStoreId: true,
team: {
select: {
agentsEnabled: true,
},
},
},
});
if (!dataroom) {
return NextResponse.json(
{ error: "Dataroom not found" },
{ status: 404 },
);
}
// Check if team has AI enabled first
if (!dataroom.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!dataroom.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this dataroom" },
{ status: 403 },
);
}
teamId = dataroom.teamId;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
} else if (documentId) {
const document = await prisma.document.findUnique({
where: { id: documentId },
select: {
teamId: true,
agentsEnabled: true,
team: {
select: {
agentsEnabled: true,
},
},
},
});
if (!document) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 404 },
);
}
// Check if team has AI enabled first
if (!document.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!document.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this document" },
{ status: 403 },
);
}
teamId = document.teamId;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
} else {
return NextResponse.json(
{ error: "Either documentId or dataroomId is required" },
{ status: 400 },
);
}
}
// External viewer flow
else if (linkId && viewId && viewerId) {
// Verify dataroom session if this is a dataroom link
if (dataroomId) {
const dataroomSession = await verifyDataroomSession(
req,
linkId,
dataroomId,
);
if (!dataroomSession || dataroomSession.viewerId !== viewerId) {
return NextResponse.json(
{ error: "Unauthorized - invalid session" },
{ status: 401 },
);
}
// Verify link has AI enabled
const link = await prisma.link.findUnique({
where: { id: linkId, dataroomId },
select: {
enableAIAgents: true,
isArchived: true,
dataroom: { select: { teamId: true, agentsEnabled: true } },
team: { select: { agentsEnabled: true } },
},
});
if (!link) {
return NextResponse.json(
{ error: "Link not found" },
{ status: 404 },
);
}
if (!link?.isArchived) {
return NextResponse.json(
{ error: "Link is archived" },
{ status: 403 },
);
}
if (!link?.enableAIAgents) {
return NextResponse.json(
{ error: "AI agents are not enabled for this link" },
{ status: 403 },
);
}
if (!link?.dataroom) {
return NextResponse.json(
{ error: "Dataroom not found" },
{ status: 404 },
);
}
if (!link?.dataroom?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this dataroom" },
{ status: 403 },
);
}
if (!link?.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled" },
{ status: 403 },
);
}
teamId = link?.dataroom?.teamId;
} else if (documentId) {
// Verify link access for document
const link = await prisma.link.findUnique({
where: { id: linkId, documentId },
select: {
enableAIAgents: true,
isArchived: true,
document: { select: { teamId: true, agentsEnabled: true } },
team: { select: { agentsEnabled: true } },
},
});
if (!link) {
return NextResponse.json(
{ error: "Link not found" },
{ status: 404 },
);
}
if (link?.isArchived) {
return NextResponse.json(
{ error: "Link is archived" },
{ status: 403 },
);
}
// Check if link has AI enabled
if (!link.enableAIAgents) {
return NextResponse.json(
{ error: "AI agents are not enabled for this link" },
{ status: 403 },
);
}
if (!link.document?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this document" },
{ status: 403 },
);
}
if (!link?.team?.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled" },
{ status: 403 },
);
}
if (!link.document || !link.document?.teamId) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 400 },
);
}
teamId = link.document?.teamId;
} else {
return NextResponse.json(
{ error: "Either documentId or dataroomId is required" },
{ status: 400 },
);
}
// Verify viewer exists
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId, teamId },
});
if (!viewer) {
return NextResponse.json(
{ error: "Viewer not found" },
{ status: 404 },
);
}
} else {
return NextResponse.json(
{ error: "Authentication required" },
{ status: 401 },
);
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get vector store ID
let vectorStoreId: string | undefined;
if (dataroomId) {
const dataroom = await prisma.dataroom.findUnique({
where: { id: dataroomId },
select: { vectorStoreId: true },
});
vectorStoreId = dataroom?.vectorStoreId || undefined;
} else if (documentId) {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: { vectorStoreId: true },
});
vectorStoreId = team?.vectorStoreId || undefined;
}
// Create the chat
const chat = await createChat({
teamId,
documentId,
dataroomId,
linkId,
viewId,
userId,
viewerId,
vectorStoreId,
title,
});
return NextResponse.json(chat, { status: 201 });
} catch (error) {
console.error("Error creating chat:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* GET /api/ai/chat
* List chats with filters
* Supports both internal users (via session) and external viewers (via viewerId param)
*/
export async function GET(req: NextRequest) {
try {
const session = await getServerSession(authOptions);
const searchParams = req.nextUrl.searchParams;
const chatListQuerySchema = z.object({
viewerId: z.string().cuid().optional(),
teamId: z.string().cuid().optional(),
documentId: z.string().cuid().optional(),
dataroomId: z.string().cuid().optional(),
linkId: z.string().cuid().optional(),
viewId: z.string().cuid().optional(),
});
const queryObj = {
viewerId: searchParams.get("viewerId") ?? undefined,
teamId: searchParams.get("teamId") ?? undefined,
documentId: searchParams.get("documentId") ?? undefined,
dataroomId: searchParams.get("dataroomId") ?? undefined,
linkId: searchParams.get("linkId") ?? undefined,
viewId: searchParams.get("viewId") ?? undefined,
};
const { viewerId, teamId, documentId, dataroomId, linkId, viewId } =
chatListQuerySchema.parse(queryObj);
// Combined flow: logged-in user with viewerId
// This handles the case where a team member is also viewing as a viewer
if (session && viewerId && (dataroomId || documentId)) {
const userId = (session.user as CustomUser).id;
// Verify viewer exists
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId },
select: {
teamId: true,
},
});
if (!viewer) {
return NextResponse.json(
{ error: "Viewer not found" },
{ status: 404 },
);
}
// Verify user is member of team that the viewer is associated with
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId: viewer.teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: viewer.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Build where clause - find chats with this userId OR viewerId
const baseWhere: any = {};
if (documentId) {
baseWhere.documentId = documentId;
}
if (dataroomId) {
baseWhere.dataroomId = dataroomId;
}
if (linkId) {
baseWhere.linkId = linkId;
}
// Fetch chats that belong to either userId or viewerId
const chats = await prisma.chat.findMany({
where: {
...baseWhere,
OR: [{ userId }, { viewerId }],
},
orderBy: {
createdAt: "desc",
},
take: 50,
});
return NextResponse.json(chats);
}
// External viewer flow (not logged in)
if (viewerId && linkId && viewId) {
// Build where clause for viewer's chats
const where: any = {
viewerId,
linkId,
};
if (dataroomId) {
// verify dataroom session
const dataroomSession = await verifyDataroomSession(
req,
linkId,
dataroomId,
);
if (!dataroomSession || dataroomSession.viewerId !== viewerId) {
return NextResponse.json(
{ error: "Unauthorized - invalid session" },
{ status: 401 },
);
}
where.dataroomId = dataroomId;
}
if (documentId) {
// verify document session
const view = await prisma.view.findUnique({
where: { id: viewId, linkId, documentId },
select: {
viewerId: true,
},
});
if (!view || view.viewerId !== viewerId) {
return NextResponse.json(
{ error: "Unauthorized - invalid session" },
{ status: 401 },
);
}
where.documentId = documentId;
}
// Fetch viewer's chats
const chats = await prisma.chat.findMany({
where,
orderBy: {
createdAt: "desc",
},
take: 50,
select: {
id: true,
title: true,
createdAt: true,
lastMessageAt: true,
},
});
return NextResponse.json(chats);
}
// Internal user flow (logged in, no viewerId)
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
if (!teamId) {
return NextResponse.json(
{ error: "teamId is required" },
{ status: 400 },
);
}
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Build where clause
const where: any = {
teamId,
userId, // Only show user's own chats
};
if (documentId) {
where.documentId = documentId;
}
if (dataroomId) {
where.dataroomId = dataroomId;
}
// Fetch chats
const chats = await prisma.chat.findMany({
where,
orderBy: {
createdAt: "desc",
},
take: 50,
});
return NextResponse.json(chats);
} catch (error) {
console.error("Error fetching chats:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,213 @@
import { NextRequest, NextResponse } from "next/server";
import { processDocumentForVectorStore } from "@/ee/features/ai/lib/file-processing/process-document-for-vector-store";
import { createDataroomVectorStore } from "@/ee/features/ai/lib/vector-stores/create-dataroom-vector-store";
import { addFileToVectorStore } from "@/ee/features/ai/lib/vector-stores/upload-file-to-vector-store";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/store/teams/[teamId]/datarooms/[dataroomId]
* Index all documents in a dataroom into its vector store
*/
export async function POST(
req: NextRequest,
{ params }: { params: { dataroomId: string; teamId: string } },
) {
try {
const { dataroomId, teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get dataroom and documents
const dataroom = await prisma.dataroom.findUnique({
where: {
id: dataroomId,
teamId,
},
include: {
team: {
select: {
agentsEnabled: true,
},
},
documents: {
include: {
document: {
include: {
versions: {
where: { isPrimary: true },
take: 1,
},
},
},
},
},
},
});
if (!dataroom) {
return NextResponse.json(
{ error: "Dataroom not found" },
{ status: 404 },
);
}
// Check if team has AI enabled
if (!dataroom.team.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!dataroom.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this dataroom" },
{ status: 403 },
);
}
// Create or get vector store
let vectorStoreId = dataroom.vectorStoreId;
if (!vectorStoreId) {
vectorStoreId = await createDataroomVectorStore({
dataroomId,
teamId: dataroom.teamId,
name: dataroom.name,
});
// Update dataroom with vector store ID
await prisma.dataroom.update({
where: { id: dataroomId },
data: { vectorStoreId },
});
}
// Process and upload documents
let filesIndexed = 0;
const errors: string[] = [];
for (const dataroomDoc of dataroom.documents) {
const document = dataroomDoc.document;
const primaryVersion = document.versions[0];
if (!primaryVersion) {
errors.push(`No primary version for document: ${document.name}`);
continue;
}
// Skip if already indexed
if (dataroomDoc.vectorStoreFileId) {
filesIndexed++;
continue;
}
try {
// Check if document type is supported
const supportedTypes = ["application/pdf"];
if (!supportedTypes.includes(primaryVersion.contentType || "")) {
errors.push(
`Unsupported file type for document: ${document.name} (${primaryVersion.contentType})`,
);
continue;
}
// get version's fileId
let fileId = primaryVersion.fileId;
if (!fileId) {
const { fileId: newFileId } = await processDocumentForVectorStore(
primaryVersion.file,
primaryVersion.storageType,
);
// Update document version with file ID
await prisma.documentVersion.update({
where: { id: primaryVersion.id },
data: { fileId },
});
fileId = newFileId;
}
const metadata = {
teamId: dataroom.teamId,
documentId: document.id,
documentName: document.name,
versionId: primaryVersion.id,
dataroomId: dataroom.id,
dataroomDocumentId: dataroomDoc.id,
dataroomFolderId: dataroomDoc.folderId || "root",
};
// Add to vector store
const vectorStoreFileId = await addFileToVectorStore({
vectorStoreId,
fileId,
metadata,
});
// Update dataroom document with vector store file ID
await prisma.dataroomDocument.update({
where: { id: dataroomDoc.id },
data: { vectorStoreFileId },
});
filesIndexed++;
} catch (error) {
console.error(`Error indexing document ${document.name}:`, error);
errors.push(
`Failed to index document: ${document.name} - ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
return NextResponse.json({
success: true,
filesIndexed,
totalDocuments: dataroom.documents.length,
vectorStoreId,
errors: errors.length > 0 ? errors : undefined,
});
} catch (error) {
console.error("Error indexing dataroom:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,263 @@
import { NextRequest, NextResponse } from "next/server";
import { processDocumentForVectorStore } from "@/ee/features/ai/lib/file-processing/process-document-for-vector-store";
import { createTeamVectorStore } from "@/ee/features/ai/lib/vector-stores/create-team-vector-store";
import { removeFileFromVectorStore } from "@/ee/features/ai/lib/vector-stores/remove-file-from-vector-store";
import { addFileToVectorStore } from "@/ee/features/ai/lib/vector-stores/upload-file-to-vector-store";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* POST /api/ai/store/teams/[teamId]/documents/[documentId]
* Index a single document into the team vector store
*/
export async function POST(
req: NextRequest,
{ params }: { params: { documentId: string; teamId: string } },
) {
try {
const { documentId, teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId,
},
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get document and version
const document = await prisma.document.findUnique({
where: { id: documentId },
include: {
team: {
select: {
agentsEnabled: true,
vectorStoreId: true,
name: true,
},
},
versions: {
where: { isPrimary: true },
take: 1,
},
},
});
if (!document) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 404 },
);
}
// Check if team has AI enabled
if (!document.team.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this team" },
{ status: 403 },
);
}
if (!document.agentsEnabled) {
return NextResponse.json(
{ error: "AI agents are not enabled for this document" },
{ status: 403 },
);
}
const primaryVersion = document.versions[0];
if (!primaryVersion) {
return NextResponse.json(
{ error: "No primary version found for this document" },
{ status: 400 },
);
}
// Create or get team vector store
let vectorStoreId = document.team.vectorStoreId;
if (!vectorStoreId) {
vectorStoreId = await createTeamVectorStore(
document.teamId,
document.team.name,
);
// Update team with vector store ID
await prisma.team.update({
where: { id: document.teamId },
data: { vectorStoreId },
});
}
// Check if document type is supported
const supportedTypes = ["application/pdf"];
if (!supportedTypes.includes(primaryVersion.contentType || "")) {
return NextResponse.json(
{
error: `Unsupported file type: ${primaryVersion.contentType}. Only PDF files are supported.`,
},
{ status: 400 },
);
}
let fileId = primaryVersion.fileId;
if (!fileId) {
const { fileId: newFileId } = await processDocumentForVectorStore(
primaryVersion.file,
primaryVersion.storageType,
);
fileId = newFileId;
}
const metadata = {
teamId: document.teamId,
documentId: document.id,
documentName: document.name,
versionId: primaryVersion.id,
folderId: document.folderId || "root",
};
// Add to vector store
const vectorStoreFileId = await addFileToVectorStore({
vectorStoreId,
fileId,
metadata,
});
// Update document version with vector store file ID
await prisma.documentVersion.update({
where: { id: primaryVersion.id },
data: { vectorStoreFileId },
});
return NextResponse.json({
success: true,
vectorStoreFileId,
vectorStoreId,
});
} catch (error) {
console.error("Error indexing document:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
/**
* DELETE /api/ai/store/teams/[teamId]/documents/[documentId]
* Remove a document from the team vector store
*/
export async function DELETE(
req: NextRequest,
{ params }: { params: { documentId: string } },
) {
try {
const { documentId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Get document and verify user access
const document = await prisma.document.findUnique({
where: { id: documentId },
include: {
team: {
include: {
users: {
where: { userId },
},
},
},
versions: {
where: { isPrimary: true },
take: 1,
},
},
});
if (!document) {
return NextResponse.json(
{ error: "Document not found" },
{ status: 404 },
);
}
if (document.team.users.length === 0) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId: document.teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
const primaryVersion = document.versions[0];
const vectorStoreId = document.team.vectorStoreId;
if (!primaryVersion?.vectorStoreFileId || !vectorStoreId) {
return NextResponse.json(
{ error: "Document is not indexed" },
{ status: 400 },
);
}
// Remove file from vector store
await removeFileFromVectorStore(
vectorStoreId,
primaryVersion.vectorStoreFileId,
);
// Clear vector store file ID
await prisma.documentVersion.update({
where: { id: primaryVersion.id },
data: { vectorStoreFileId: null },
});
return NextResponse.json({ success: true });
} catch (error) {
console.error("Error removing document from vector store:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
@@ -0,0 +1,86 @@
import { NextRequest, NextResponse } from "next/server";
import { getVectorStoreInfo } from "@/ee/features/ai/lib/vector-stores/get-vector-store-info";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
/**
* GET /api/ai/store/teams/[teamId]
* Get team vector store information
*/
export async function GET(
req: NextRequest,
{ params }: { params: { teamId: string } },
) {
try {
const { teamId } = params;
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const userId = (session.user as CustomUser).id;
// Verify user is member of team
const userTeam = await prisma.userTeam.findFirst({
where: {
userId,
teamId,
},
});
if (!userTeam) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return NextResponse.json(
{ error: "AI features are not available for this team" },
{ status: 403 },
);
}
// Get team with vector store ID
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
vectorStoreId: true,
agentsEnabled: true,
},
});
if (!team) {
return NextResponse.json({ error: "Team not found" }, { status: 404 });
}
if (!team.vectorStoreId) {
return NextResponse.json({
agentsEnabled: team.agentsEnabled,
vectorStoreId: null,
info: null,
});
}
// Get vector store info
const info = await getVectorStoreInfo(team.vectorStoreId);
return NextResponse.json({
agentsEnabled: team.agentsEnabled,
vectorStoreId: team.vectorStoreId,
info,
});
} catch (error) {
console.error("Error fetching team vector store info:", error);
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+11
View File
@@ -132,6 +132,7 @@ export async function POST(request: NextRequest) {
select: {
plan: true,
globalBlockList: true,
agentsEnabled: true,
},
},
customFields: {
@@ -141,6 +142,12 @@ export async function POST(request: NextRequest) {
},
},
enableUpload: true,
dataroom: {
select: {
agentsEnabled: true,
name: true,
},
},
},
});
@@ -703,6 +710,8 @@ export async function POST(request: NextRequest) {
viewerId: viewer?.id,
conversationsEnabled: link.enableConversation,
enableVisitorUpload: link.enableUpload,
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
dataroomName: link.dataroom?.name,
...(isTeamMember && { isTeamMember: true }),
};
@@ -1014,6 +1023,8 @@ export async function POST(request: NextRequest) {
canDownload: canDownload,
viewerId: viewer?.id,
conversationsEnabled: link.enableConversation,
agentsEnabled: link.dataroom?.agentsEnabled ?? false,
dataroomName: link.dataroom?.name,
...(isTeamMember && { isTeamMember: true }),
};
+12
View File
@@ -108,6 +108,7 @@ export async function POST(request: NextRequest) {
select: {
plan: true,
globalBlockList: true,
agentsEnabled: true,
},
},
customFields: {
@@ -116,6 +117,11 @@ export async function POST(request: NextRequest) {
label: true,
},
},
document: {
select: {
agentsEnabled: true,
},
},
},
});
@@ -660,9 +666,14 @@ export async function POST(request: NextRequest) {
}
}
// Determine if AI agents should be enabled (requires both team and document level)
const agentsEnabled =
link.team?.agentsEnabled && link.document?.agentsEnabled;
const returnObject = {
message: "View recorded",
viewId: !isPreview && newView ? newView.id : undefined,
viewerId: viewer?.id ?? undefined,
isPreview: isPreview ? true : undefined,
file:
(documentVersion &&
@@ -700,6 +711,7 @@ export async function POST(request: NextRequest) {
: undefined,
verificationToken: hashedVerificationToken ?? undefined,
...(isTeamMember && { isTeamMember: true }),
...(agentsEnabled && { agentsEnabled: true }),
};
return NextResponse.json(returnObject);
+100
View File
@@ -0,0 +1,100 @@
"use client";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { ArrowDownIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
export type ConversationProps = ComponentProps<typeof StickToBottom>;
export const Conversation = ({ className, ...props }: ConversationProps) => (
<StickToBottom
className={cn("relative flex-1 overflow-y-hidden", className)}
initial="smooth"
resize="smooth"
role="log"
{...props}
/>
);
export type ConversationContentProps = ComponentProps<
typeof StickToBottom.Content
>;
export const ConversationContent = ({
className,
...props
}: ConversationContentProps) => (
<StickToBottom.Content
className={cn("flex flex-col gap-8 p-4", className)}
{...props}
/>
);
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
title?: string;
description?: string;
icon?: React.ReactNode;
};
export const ConversationEmptyState = ({
className,
title = "No messages yet",
description = "Start a conversation to see messages here",
icon,
children,
...props
}: ConversationEmptyStateProps) => (
<div
className={cn(
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
className
)}
{...props}
>
{children ?? (
<>
{icon && <div className="text-muted-foreground">{icon}</div>}
<div className="space-y-1">
<h3 className="font-medium text-sm">{title}</h3>
{description && (
<p className="text-muted-foreground text-sm">{description}</p>
)}
</div>
</>
)}
</div>
);
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
export const ConversationScrollButton = ({
className,
...props
}: ConversationScrollButtonProps) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleScrollToBottom = useCallback(() => {
scrollToBottom();
}, [scrollToBottom]);
return (
!isAtBottom && (
<Button
className={cn(
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
className
)}
onClick={handleScrollToBottom}
size="icon"
type="button"
variant="outline"
{...props}
>
<ArrowDownIcon className="size-4" />
</Button>
)
);
};
+448
View File
@@ -0,0 +1,448 @@
"use client";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import { createContext, memo, useContext, useEffect, useState } from "react";
import type { FileUIPart, UIMessage } from "ai";
import {
ChevronLeftIcon,
ChevronRightIcon,
PaperclipIcon,
XIcon,
} from "lucide-react";
import { Streamdown } from "streamdown";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
<div
className={cn(
"group flex w-full max-w-[80%] flex-col gap-2",
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
className,
)}
{...props}
/>
);
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageContent = ({
children,
className,
...props
}: MessageContentProps) => (
<div
className={cn(
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
"group-[.is-assistant]:text-foreground",
className,
)}
{...props}
>
{children}
</div>
);
export type MessageActionsProps = ComponentProps<"div">;
export const MessageActions = ({
className,
children,
...props
}: MessageActionsProps) => (
<div className={cn("flex items-center gap-1", className)} {...props}>
{children}
</div>
);
export type MessageActionProps = ComponentProps<typeof Button> & {
tooltip?: string;
label?: string;
};
export const MessageAction = ({
tooltip,
children,
label,
variant = "ghost",
size = "icon",
...props
}: MessageActionProps) => {
const button = (
<Button size={size} type="button" variant={variant} {...props}>
{children}
<span className="sr-only">{label || tooltip}</span>
</Button>
);
if (tooltip) {
return (
<TooltipProvider delayDuration={100}>
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent side="bottom" className="px-1.5 py-1 text-sm">
<span>{tooltip}</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return button;
};
type MessageBranchContextType = {
currentBranch: number;
totalBranches: number;
goToPrevious: () => void;
goToNext: () => void;
branches: ReactElement[];
setBranches: (branches: ReactElement[]) => void;
};
const MessageBranchContext = createContext<MessageBranchContextType | null>(
null,
);
const useMessageBranch = () => {
const context = useContext(MessageBranchContext);
if (!context) {
throw new Error(
"MessageBranch components must be used within MessageBranch",
);
}
return context;
};
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
defaultBranch?: number;
onBranchChange?: (branchIndex: number) => void;
};
export const MessageBranch = ({
defaultBranch = 0,
onBranchChange,
className,
...props
}: MessageBranchProps) => {
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
const [branches, setBranches] = useState<ReactElement[]>([]);
const handleBranchChange = (newBranch: number) => {
setCurrentBranch(newBranch);
onBranchChange?.(newBranch);
};
const goToPrevious = () => {
const newBranch =
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
handleBranchChange(newBranch);
};
const goToNext = () => {
const newBranch =
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
handleBranchChange(newBranch);
};
const contextValue: MessageBranchContextType = {
currentBranch,
totalBranches: branches.length,
goToPrevious,
goToNext,
branches,
setBranches,
};
return (
<MessageBranchContext.Provider value={contextValue}>
<div
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
{...props}
/>
</MessageBranchContext.Provider>
);
};
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
export const MessageBranchContent = ({
children,
...props
}: MessageBranchContentProps) => {
const { currentBranch, setBranches, branches } = useMessageBranch();
const childrenArray = Array.isArray(children) ? children : [children];
// Use useEffect to update branches when they change
useEffect(() => {
if (branches.length !== childrenArray.length) {
setBranches(childrenArray);
}
}, [childrenArray, branches, setBranches]);
return childrenArray.map((branch, index) => (
<div
className={cn(
"grid gap-2 overflow-hidden [&>div]:pb-0",
index === currentBranch ? "block" : "hidden",
)}
key={branch.key}
{...props}
>
{branch}
</div>
));
};
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
};
export const MessageBranchSelector = ({
className,
from,
...props
}: MessageBranchSelectorProps) => {
const { totalBranches } = useMessageBranch();
// Don't render if there's only one branch
if (totalBranches <= 1) {
return null;
}
return (
<ButtonGroup
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
orientation="horizontal"
{...props}
/>
);
};
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronLeftIcon size={14} />}
</Button>
);
};
export type MessageBranchNextProps = ComponentProps<typeof Button>;
export const MessageBranchNext = ({
children,
className,
...props
}: MessageBranchNextProps) => {
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon"
type="button"
variant="ghost"
{...props}
>
{children ?? <ChevronRightIcon size={14} />}
</Button>
);
};
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { currentBranch, totalBranches } = useMessageBranch();
return (
<ButtonGroupText
className={cn(
"border-none bg-transparent text-muted-foreground shadow-none",
className,
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
</ButtonGroupText>
);
};
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
export const MessageResponse = memo(
({ className, ...props }: MessageResponseProps) => (
<Streamdown
className={cn(
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0 [&_li>p]:m-0 [&_li>p]:inline",
className,
)}
{...props}
/>
),
(prevProps, nextProps) => prevProps.children === nextProps.children,
);
MessageResponse.displayName = "MessageResponse";
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
data: FileUIPart;
className?: string;
onRemove?: () => void;
};
export function MessageAttachment({
data,
className,
onRemove,
...props
}: MessageAttachmentProps) {
const filename = data.filename || "";
const mediaType =
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
const isImage = mediaType === "image";
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
return (
<div
className={cn(
"group relative size-24 overflow-hidden rounded-lg",
className,
)}
{...props}
>
{isImage ? (
<>
<img
alt={filename || "attachment"}
className="size-full object-cover"
height={100}
src={data.url}
width={100}
/>
{onRemove && (
<Button
aria-label="Remove attachment"
className="absolute right-2 top-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
) : (
<>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
<PaperclipIcon className="size-4" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>{attachmentLabel}</p>
</TooltipContent>
</Tooltip>
{onRemove && (
<Button
aria-label="Remove attachment"
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
type="button"
variant="ghost"
>
<XIcon />
<span className="sr-only">Remove</span>
</Button>
)}
</>
)}
</div>
);
}
export type MessageAttachmentsProps = ComponentProps<"div">;
export function MessageAttachments({
children,
className,
...props
}: MessageAttachmentsProps) {
if (!children) {
return null;
}
return (
<div
className={cn(
"ml-auto flex w-fit flex-wrap items-start gap-2",
className,
)}
{...props}
>
{children}
</div>
);
}
export type MessageToolbarProps = ComponentProps<"div">;
export const MessageToolbar = ({
className,
children,
...props
}: MessageToolbarProps) => (
<div
className={cn(
"mt-4 flex w-full items-center justify-between gap-4",
className,
)}
{...props}
>
{children}
</div>
);
File diff suppressed because it is too large Load Diff
+64
View File
@@ -0,0 +1,64 @@
"use client";
import { cn } from "@/lib/utils";
import { motion } from "motion/react";
import {
type CSSProperties,
type ElementType,
type JSX,
memo,
useMemo,
} from "react";
export type TextShimmerProps = {
children: string;
as?: ElementType;
className?: string;
duration?: number;
spread?: number;
};
const ShimmerComponent = ({
children,
as: Component = "p",
className,
duration = 2,
spread = 2,
}: TextShimmerProps) => {
const MotionComponent = motion.create(
Component as keyof JSX.IntrinsicElements
);
const dynamicSpread = useMemo(
() => (children?.length ?? 0) * spread,
[children, spread]
);
return (
<MotionComponent
animate={{ backgroundPosition: "0% center" }}
className={cn(
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
className
)}
initial={{ backgroundPosition: "100% center" }}
style={
{
"--spread": `${dynamicSpread}px`,
backgroundImage:
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
} as CSSProperties
}
transition={{
repeat: Number.POSITIVE_INFINITY,
duration,
ease: "linear",
}}
>
{children}
</MotionComponent>
);
};
export const Shimmer = memo(ShimmerComponent);
+34
View File
@@ -9,6 +9,7 @@ import { Document, DocumentVersion } from "@prisma/client";
import {
ArrowRightIcon,
BetweenHorizontalStartIcon,
Bot,
ChevronRight,
CloudDownloadIcon,
DownloadIcon,
@@ -25,9 +26,11 @@ import { useTheme } from "next-themes";
import { toast } from "sonner";
import { mutate } from "swr";
import { DocumentAIDialog } from "@/ee/features/ai/components/document-ai-dialog";
import { getFile } from "@/lib/files/get-file";
import { usePlan } from "@/lib/swr/use-billing";
import useDatarooms from "@/lib/swr/use-datarooms";
import { useTeamAI } from "@/lib/swr/use-team-ai";
import {
DocumentWithLinksAndLinkCountAndViewCount,
DocumentWithVersion,
@@ -83,6 +86,7 @@ export default function DocumentHeader({
const isLight =
theme === "light" || (theme === "system" && systemTheme === "light");
const { isPro, isFree, isTrial, isBusiness, isDatarooms } = usePlan();
const { canUseAI, isAIEnabled } = useTeamAI();
const [isEditingName, setIsEditingName] = useState<boolean>(false);
const [menuOpen, setMenuOpen] = useState<boolean>(false);
const [isFirstClick, setIsFirstClick] = useState<boolean>(false);
@@ -94,6 +98,7 @@ export default function DocumentHeader({
const [planModalTrigger, setPlanModalTrigger] = useState<string>("");
const [selectedPlan, setSelectedPlan] = useState<PlanEnum>(PlanEnum.Pro);
const [exportModalOpen, setExportModalOpen] = useState<boolean>(false);
const [aiDialogOpen, setAiDialogOpen] = useState<boolean>(false);
const nameRef = useRef<HTMLHeadingElement>(null);
const enterPressedRef = useRef<boolean>(false);
const dropdownRef = useRef<HTMLDivElement | null>(null);
@@ -735,6 +740,25 @@ export default function DocumentHeader({
</DropdownMenuItem>
)}
{/* AI Agents - only show when team has AI enabled */}
{isAIEnabled &&
prismaDocument.type !== "notion" &&
prismaDocument.type !== "sheet" &&
prismaDocument.type !== "zip" &&
primaryVersion.type !== "video" && (
<DropdownMenuItem
onClick={() => {
setAiDialogOpen(true);
setMenuOpen(false);
}}
>
<Bot className="mr-2 h-4 w-4" />
{prismaDocument.agentsEnabled
? "AI Agents Settings"
: "Enable AI Agents"}
</DropdownMenuItem>
)}
{primaryVersion.type !== "notion" &&
primaryVersion.type !== "zip" &&
primaryVersion.type !== "map" &&
@@ -982,6 +1006,16 @@ export default function DocumentHeader({
onClose={() => setExportModalOpen(false)}
/>
)}
{/* AI Agents Dialog */}
<DocumentAIDialog
open={aiDialogOpen}
onOpenChange={setAiDialogOpen}
documentId={prismaDocument.id}
teamId={teamId}
agentsEnabled={prismaDocument.agentsEnabled}
vectorStoreFileId={primaryVersion.vectorStoreFileId}
/>
</header>
);
}
@@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { DEFAULT_LINK_TYPE } from "@/components/links/link-sheet";
import LinkItem from "@/components/links/link-sheet/link-item";
import { LinkUpgradeOptions } from "./link-options";
export default function AIAgentsSection({
data,
setData,
isAllowed,
handleUpgradeStateChange,
}: {
data: DEFAULT_LINK_TYPE;
setData: React.Dispatch<React.SetStateAction<DEFAULT_LINK_TYPE>>;
isAllowed: boolean;
handleUpgradeStateChange: ({
state,
trigger,
plan,
}: LinkUpgradeOptions) => void;
}) {
const { isFeatureEnabled, isLoading: featuresLoading } = useFeatureFlags();
const isAIFeatureEnabled = isFeatureEnabled("ai");
const { enableAIAgents } = data;
const [enabled, setEnabled] = useState<boolean>(false);
useEffect(() => {
setEnabled(enableAIAgents);
}, [enableAIAgents]);
const handleToggle = async () => {
const updatedState = !enabled;
setData({
...data,
enableAIAgents: updatedState,
});
setEnabled(updatedState);
};
// Don't render if feature flags are still loading or AI feature is not enabled
if (featuresLoading || !isAIFeatureEnabled) {
return null;
}
return (
<div className="pb-5">
<LinkItem
title="AI Agents"
enabled={enabled}
action={handleToggle}
isAllowed={isAllowed}
requiredPlan="Business"
upgradeAction={() =>
handleUpgradeStateChange({
state: true,
trigger: "link_sheet_ai_agents",
plan: "Business",
})
}
tooltipContent="Allow visitors to chat with AI about this document or dataroom. Requires AI to be enabled at the team and document/dataroom level."
/>
</div>
);
}
+2
View File
@@ -86,6 +86,7 @@ export const DEFAULT_LINK_PROPS = (
customFields: [],
tags: [],
enableConversation: false,
enableAIAgents: false,
enableUpload: false,
isFileRequestOnly: false,
uploadFolderId: null,
@@ -129,6 +130,7 @@ export type DEFAULT_LINK_TYPE = {
customFields: CustomFieldData[];
tags: string[];
enableConversation: boolean;
enableAIAgents: boolean;
enableUpload: boolean;
isFileRequestOnly: boolean;
uploadFolderId: string | null;
+43 -32
View File
@@ -29,6 +29,7 @@ import {
} from "@/components/ui/collapsible";
import AgreementSection from "./agreement-section";
import AIAgentsSection from "./ai-agents-section";
import ConversationSection from "./conversation-section";
import CustomFieldsSection from "./custom-fields-section";
import IndexFileSection from "./index-file-section";
@@ -249,43 +250,53 @@ export const LinkOptions = ({
</div>
</CollapsibleSection>
{/* Advanced Section - Only for Datarooms */}
{linkType === LinkType.DATAROOM_LINK ? (
<CollapsibleSection title="Advanced Controls" defaultOpen={true}>
<div>
{targetId ? (
<UploadSection
{...{ data, setData }}
isAllowed={
isTrial ||
isDataroomsPlus ||
(isDatarooms && limits?.dataroomUpload === true)
}
handleUpgradeStateChange={handleUpgradeStateChange}
targetId={targetId}
/>
) : null}
{/* Advanced Section */}
<CollapsibleSection title="Advanced Controls" defaultOpen={true}>
<div>
{/* AI Agents - Available for both document and dataroom links */}
<AIAgentsSection
{...{ data, setData }}
isAllowed={isTrial || isBusiness || isDatarooms || isDataroomsPlus}
handleUpgradeStateChange={handleUpgradeStateChange}
/>
<IndexFileSection
{...{ data, setData }}
isAllowed={isTrial || isDataroomsPlus}
handleUpgradeStateChange={handleUpgradeStateChange}
/>
{/* Dataroom-specific options */}
{linkType === LinkType.DATAROOM_LINK ? (
<>
{targetId ? (
<UploadSection
{...{ data, setData }}
isAllowed={
isTrial ||
isDataroomsPlus ||
(isDatarooms && limits?.dataroomUpload === true)
}
handleUpgradeStateChange={handleUpgradeStateChange}
targetId={targetId}
/>
) : null}
{limits?.conversationsInDataroom ? (
<ConversationSection
<IndexFileSection
{...{ data, setData }}
isAllowed={
isDataroomsPlus ||
((isBusiness || isDatarooms) &&
limits?.conversationsInDataroom)
}
isAllowed={isTrial || isDataroomsPlus}
handleUpgradeStateChange={handleUpgradeStateChange}
/>
) : null}
</div>
</CollapsibleSection>
) : null}
{limits?.conversationsInDataroom ? (
<ConversationSection
{...{ data, setData }}
isAllowed={
isDataroomsPlus ||
((isBusiness || isDatarooms) &&
limits?.conversationsInDataroom)
}
handleUpgradeStateChange={handleUpgradeStateChange}
/>
) : null}
</>
) : null}
</div>
</CollapsibleSection>
<UpgradePlanModal
clickedPlan={upgradePlan}
+1
View File
@@ -250,6 +250,7 @@ export default function LinksTable({
enableIndexFile: link.enableIndexFile ?? false,
permissionGroupId: link.permissionGroupId ?? null,
welcomeMessage: link.welcomeMessage ?? null,
enableAIAgents: link.enableAIAgents ?? false,
});
//wait for dropdown to close before opening the link sheet
setTimeout(() => {
+8 -14
View File
@@ -1,21 +1,9 @@
import { useTeam } from "@/context/team-context";
import useSWR from "swr";
import { fetcher } from "@/lib/utils";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { NavMenu } from "../navigation-menu";
export function SettingsHeader() {
const teamInfo = useTeam();
const { data: features } = useSWR<{
tokens: boolean;
incomingWebhooks: boolean;
}>(
teamInfo?.currentTeam?.id
? `/api/feature-flags?teamId=${teamInfo.currentTeam.id}`
: null,
fetcher,
);
const { features } = useFeatureFlags();
return (
<header>
@@ -72,6 +60,12 @@ export function SettingsHeader() {
href: `/settings/slack`,
segment: "slack",
},
{
label: "AI",
href: `/settings/ai`,
segment: "ai",
disabled: !features?.ai,
},
{
label: "Tokens",
href: `/settings/tokens`,
+83
View File
@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch has-[>[data-slot=button-group]]:gap-2 [&>*]:focus-visible:relative [&>*]:focus-visible:z-10 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+260
View File
@@ -0,0 +1,260 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
const Carousel = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & CarouselProps
>(
(
{
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
},
ref
) => {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) {
return
}
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) {
return
}
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) {
return
}
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={ref}
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
)
Carousel.displayName = "Carousel"
const CarouselContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { carouselRef, orientation } = useCarousel()
return (
<div ref={carouselRef} className="overflow-hidden">
<div
ref={ref}
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
})
CarouselContent.displayName = "CarouselContent"
const CarouselItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
})
CarouselItem.displayName = "CarouselItem"
const CarouselPrevious = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-left-12 top-1/2 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft className="h-4 w-4" />
<span className="sr-only">Previous slide</span>
</Button>
)
})
CarouselPrevious.displayName = "CarouselPrevious"
const CarouselNext = React.forwardRef<
HTMLButtonElement,
React.ComponentProps<typeof Button>
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
ref={ref}
variant={variant}
size={size}
className={cn(
"absolute h-8 w-8 rounded-full",
orientation === "horizontal"
? "-right-12 top-1/2 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight className="h-4 w-4" />
<span className="sr-only">Next slide</span>
</Button>
)
})
CarouselNext.displayName = "CarouselNext"
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+27
View File
@@ -0,0 +1,27 @@
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
const HoverCard = HoverCardPrimitive.Root
const HoverCardTrigger = HoverCardPrimitive.Trigger
const HoverCardContent = React.forwardRef<
React.ElementRef<typeof HoverCardPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<HoverCardPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-hover-card-content-transform-origin]",
className
)}
{...props}
/>
))
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName
export { HoverCard, HoverCardTrigger, HoverCardContent }
+168
View File
@@ -0,0 +1,168 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 shadow-xs relative flex w-full items-center rounded-md border outline-none transition-[color,box-shadow]",
"h-9 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"flex items-center gap-2 text-sm shadow-none",
{
variants: {
size: {
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5",
sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
@@ -59,6 +59,8 @@ export type DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE = {
viewerId?: string;
conversationsEnabled?: boolean;
isTeamMember?: boolean;
agentsEnabled?: boolean;
dataroomName?: string;
};
export default function DataroomDocumentView({
@@ -180,6 +182,8 @@ export default function DataroomDocumentView({
viewerId,
conversationsEnabled,
isTeamMember,
agentsEnabled,
dataroomName,
} = fetchData as DEFAULT_DATAROOM_DOCUMENT_VIEW_TYPE;
analytics.identify(
userEmail ?? viewerEmail ?? verifiedEmail ?? data.email ?? undefined,
@@ -224,6 +228,8 @@ export default function DataroomDocumentView({
viewerId,
conversationsEnabled,
isTeamMember,
agentsEnabled,
dataroomName,
}));
setSubmitted(true);
setVerificationRequested(false);
@@ -43,6 +43,8 @@ export type DEFAULT_DATAROOM_VIEW_TYPE = {
conversationsEnabled?: boolean;
enableVisitorUpload?: boolean;
isTeamMember?: boolean;
agentsEnabled?: boolean;
dataroomName?: string;
};
export default function DataroomView({
@@ -146,6 +148,8 @@ export default function DataroomView({
conversationsEnabled,
enableVisitorUpload,
isTeamMember,
agentsEnabled,
dataroomName,
} = fetchData as DEFAULT_DATAROOM_VIEW_TYPE;
analytics.identify(
@@ -181,6 +185,8 @@ export default function DataroomView({
conversationsEnabled,
enableVisitorUpload,
isTeamMember,
agentsEnabled,
dataroomName,
});
setSubmitted(true);
setVerificationRequested(false);
+6
View File
@@ -45,6 +45,8 @@ export type DEFAULT_DOCUMENT_VIEW_TYPE = {
ipAddress?: string;
verificationToken?: string;
isTeamMember?: boolean;
agentsEnabled?: boolean;
viewerId?: string;
};
export default function DocumentView({
@@ -157,7 +159,9 @@ export default function DocumentView({
isPreview,
ipAddress,
verificationToken,
agentsEnabled,
isTeamMember,
viewerId,
} = fetchData as DEFAULT_DOCUMENT_VIEW_TYPE;
analytics.identify(
userEmail ?? verifiedEmail ?? data.email ?? undefined,
@@ -193,6 +197,8 @@ export default function DocumentView({
isPreview,
ipAddress,
isTeamMember,
agentsEnabled,
viewerId,
});
setSubmitted(true);
setVerificationRequested(false);
+10 -2
View File
@@ -3,6 +3,7 @@ import { useRouter } from "next/router";
import React, { useEffect, useState } from "react";
import { useViewerChatSafe } from "@/ee/features/ai/components/viewer-chat-provider";
import { Brand, DataroomBrand } from "@prisma/client";
import {
ArrowUpRight,
@@ -93,6 +94,10 @@ export default function Nav({
const asPath = router.asPath;
const { previewToken, preview } = router.query;
// Get chat context to adjust navbar when chat is open
const chatContext = useViewerChatSafe();
const isChatOpen = chatContext?.isOpen && chatContext?.isEnabled;
const {
linkId,
allowDownload,
@@ -193,8 +198,8 @@ export default function Nav({
})();
toast.promise(downloadPromise, {
loading: hasWatermark
? "Preparing download with watermark..."
loading: hasWatermark
? "Preparing download with watermark..."
: "Preparing download...",
success: (message) => message,
error: (err) => err.message || "Failed to download file",
@@ -232,6 +237,9 @@ export default function Nav({
className="bg-black"
style={{
backgroundColor: brand && brand.brandColor ? brand.brandColor : "black",
// Extend navbar to full width when chat panel is open (counteract parent padding)
marginRight: isChatOpen ? "-400px" : undefined,
// paddingRight: isChatOpen ? "400px" : undefined,
}}
>
<div className="mx-auto px-2 sm:px-6 lg:px-8">
+132 -89
View File
@@ -1,5 +1,11 @@
import dynamic from "next/dynamic";
import { ViewerChatPanel } from "@/ee/features/ai/components/viewer-chat-panel";
import {
ViewerChatLayout,
ViewerChatProvider,
} from "@/ee/features/ai/components/viewer-chat-provider";
import { ViewerChatToggle } from "@/ee/features/ai/components/viewer-chat-toggle";
import {
Brand,
DataroomBrand,
@@ -104,95 +110,132 @@ export default function ViewData({
// Calculate allowDownload once for all components
return notionData?.recordMap ? (
<NotionPage
recordMap={notionData.recordMap}
versionNumber={document.versions[0].versionNumber}
theme={notionData.theme}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
navData={navData}
/>
) : document.downloadOnly ? (
<DownloadOnlyViewer
versionNumber={document.versions[0].versionNumber}
// Check if agents are enabled (returned from views API after access is granted)
const agentsEnabled =
"agentsEnabled" in viewData ? viewData.agentsEnabled : false;
// Determine dataroom name if applicable
const dataroomName =
dataroomId && "dataroomName" in viewData
? viewData.dataroomName
: undefined;
return (
<ViewerChatProvider
enabled={agentsEnabled}
documentId={document.id}
documentName={document.name}
navData={navData}
/>
) : viewData.fileType === "sheet" && viewData.sheetData ? (
<ExcelViewer
versionNumber={document.versions[0].versionNumber}
sheetData={viewData.sheetData}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
navData={navData}
/>
) : viewData.fileType === "sheet" && useAdvancedExcelViewer ? (
<AdvancedExcelViewer
file={viewData.file!}
versionNumber={document.versions[0].versionNumber}
navData={navData}
/>
) : viewData.fileType === "image" ? (
<ImageViewer
file={viewData.file!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark ? (link.watermarkConfig as WatermarkConfig) : null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.pages && !document.versions[0].isVertical ? (
<PagesHorizontalViewer
pages={viewData.pages}
feedbackEnabled={link.enableFeedback!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
showAccountCreationSlide={showAccountCreationSlide}
enableQuestion={link.enableQuestion}
feedback={link.feedback}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark ? (link.watermarkConfig as WatermarkConfig) : null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.pages && document.versions[0].isVertical ? (
<PagesVerticalViewer
pages={viewData.pages}
feedbackEnabled={link.enableFeedback!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
enableQuestion={link.enableQuestion}
feedback={link.feedback}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark ? (link.watermarkConfig as WatermarkConfig) : null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.fileType === "video" ? (
<VideoViewer
file={viewData.file!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
navData={navData}
/>
) : (
<PDFViewer
file={viewData.file}
name={document.name}
versionNumber={document.versions[0].versionNumber}
navData={navData}
/>
dataroomId={dataroomId}
dataroomName={dataroomName}
linkId={link.id}
viewId={viewData.viewId}
viewerId={"viewerId" in viewData ? viewData.viewerId : undefined}
// focusedDocumentId={dataroomId ? document.id : undefined}
// focusedDocumentName={dataroomId ? document.name : undefined}
>
<ViewerChatLayout>
{notionData?.recordMap ? (
<NotionPage
recordMap={notionData.recordMap}
versionNumber={document.versions[0].versionNumber}
theme={notionData.theme}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
navData={navData}
/>
) : document.downloadOnly ? (
<DownloadOnlyViewer
versionNumber={document.versions[0].versionNumber}
documentName={document.name}
navData={navData}
/>
) : viewData.fileType === "sheet" && viewData.sheetData ? (
<ExcelViewer
versionNumber={document.versions[0].versionNumber}
sheetData={viewData.sheetData}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
navData={navData}
/>
) : viewData.fileType === "sheet" && useAdvancedExcelViewer ? (
<AdvancedExcelViewer
file={viewData.file!}
versionNumber={document.versions[0].versionNumber}
navData={navData}
/>
) : viewData.fileType === "image" ? (
<ImageViewer
file={viewData.file!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark
? (link.watermarkConfig as WatermarkConfig)
: null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.pages && !document.versions[0].isVertical ? (
<PagesHorizontalViewer
pages={viewData.pages}
feedbackEnabled={link.enableFeedback!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
showAccountCreationSlide={showAccountCreationSlide}
enableQuestion={link.enableQuestion}
feedback={link.feedback}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark
? (link.watermarkConfig as WatermarkConfig)
: null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.pages && document.versions[0].isVertical ? (
<PagesVerticalViewer
pages={viewData.pages}
feedbackEnabled={link.enableFeedback!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
showPoweredByBanner={showPoweredByBanner}
enableQuestion={link.enableQuestion}
feedback={link.feedback}
viewerEmail={viewerEmail}
watermarkConfig={
link.enableWatermark
? (link.watermarkConfig as WatermarkConfig)
: null
}
ipAddress={viewData.ipAddress}
linkName={link.name ?? `Link #${link.id.slice(-5)}`}
navData={navData}
/>
) : viewData.fileType === "video" ? (
<VideoViewer
file={viewData.file!}
screenshotProtectionEnabled={link.enableScreenshotProtection!}
versionNumber={document.versions[0].versionNumber}
navData={navData}
/>
) : (
<PDFViewer
file={viewData.file}
name={document.name}
versionNumber={document.versions[0].versionNumber}
navData={navData}
/>
)}
</ViewerChatLayout>
{/* AI Chat Components */}
<ViewerChatPanel />
<ViewerChatToggle />
</ViewerChatProvider>
);
}
+155 -133
View File
@@ -3,6 +3,12 @@ import { useRouter } from "next/router";
import { useMemo } from "react";
import React from "react";
import { ViewerChatPanel } from "@/ee/features/ai/components/viewer-chat-panel";
import {
ViewerChatLayout,
ViewerChatProvider,
} from "@/ee/features/ai/components/viewer-chat-provider";
import { ViewerChatToggle } from "@/ee/features/ai/components/viewer-chat-toggle";
import {
DataroomBrand,
DataroomFolder,
@@ -368,7 +374,14 @@ export default function DataroomViewer({
};
return (
<>
<ViewerChatProvider
enabled={viewData.agentsEnabled}
dataroomId={dataroom?.id}
dataroomName={viewData.dataroomName}
linkId={linkId}
viewId={viewId}
viewerId={viewerId}
>
<DataroomNav
brand={brand}
linkId={linkId}
@@ -382,151 +395,160 @@ export default function DataroomViewer({
conversationsEnabled={viewData.conversationsEnabled}
isTeamMember={viewData.isTeamMember}
/>
<div
style={{ height: "calc(100vh - 64px)" }}
className="relative flex items-center bg-white dark:bg-black"
>
<div className="relative mx-auto flex h-full w-full items-start justify-center">
{/* Tree view */}
<div className="hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14">
<ScrollArea showScrollbar className="w-full">
<ViewFolderTree
folders={folders}
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</ScrollArea>
</div>
<ViewerChatLayout>
<div className="relative flex flex-1 items-center overflow-hidden bg-white dark:bg-black">
<div className="relative mx-auto flex h-full w-full items-start justify-center">
{/* Tree view */}
<div className="hidden h-full w-1/4 space-y-8 overflow-auto px-3 pb-4 pt-4 md:flex md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14">
<ScrollArea showScrollbar className="w-full">
<ViewFolderTree
folders={folders}
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
<ScrollBar orientation="horizontal" />
<ScrollBar orientation="vertical" />
</ScrollArea>
</div>
{/* Detail view */}
<ScrollArea showScrollbar className="h-full flex-grow overflow-auto">
<div className="h-full px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14">
<div className="flex items-center gap-x-2">
{/* sidebar for mobile */}
<div className="flex md:hidden">
<Sheet>
<SheetTrigger asChild>
<button className="text-muted-foreground lg:hidden">
<PanelLeftIcon className="h-5 w-5" aria-hidden="true" />
</button>
</SheetTrigger>
<SheetPortal>
<SheetOverlay className="fixed top-[35dvh] z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<SheetPrimitive.Content
className={cn(
"fixed top-[35dvh] z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
"left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-lg",
"m-0 w-[280px] p-0 sm:w-[300px] lg:hidden",
)}
>
<div className="mt-8 h-full space-y-8 overflow-auto px-2 py-3">
<ViewFolderTree
folders={folders}
documents={documents}
setFolderId={setFolderId}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
{/* Detail view */}
<ScrollArea
showScrollbar
className="h-full flex-grow overflow-auto"
>
<div className="h-full px-3 pb-4 pt-4 md:px-6 md:pt-6 lg:px-8 lg:pt-9 xl:px-14">
<div className="flex items-center gap-x-2">
{/* sidebar for mobile */}
<div className="flex md:hidden">
<Sheet>
<SheetTrigger asChild>
<button className="text-muted-foreground lg:hidden">
<PanelLeftIcon
className="h-5 w-5"
aria-hidden="true"
/>
</div>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
</Sheet>
</div>
<div className="flex flex-1 items-center justify-between gap-x-2">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem key={"root"}>
<BreadcrumbLink
onClick={() => setFolderId(null)}
className="cursor-pointer"
</button>
</SheetTrigger>
<SheetPortal>
<SheetOverlay className="fixed top-[35dvh] z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<SheetPrimitive.Content
className={cn(
"fixed top-[35dvh] z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
"left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-lg",
"m-0 w-[280px] p-0 sm:w-[300px] lg:hidden",
)}
>
Home
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbFolders.map((folder, index) => (
<React.Fragment key={folder.id}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<ViewerBreadcrumbItem
folder={folder}
<div className="mt-8 h-full space-y-8 overflow-auto px-2 py-3">
<ViewFolderTree
folders={folders}
documents={documents}
setFolderId={setFolderId}
isLast={index === breadcrumbFolders.length - 1}
folderId={folderId}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center gap-x-2">
<SearchBoxPersisted inputClassName="h-9" />
{enableIndexFile && viewId && viewerId && (
<IndexFileDialog
linkId={linkId}
viewId={viewId}
dataroomId={dataroom?.id}
viewerId={viewerId}
viewerEmail={viewerEmail}
/>
)}
{viewData?.enableVisitorUpload && viewerId && (
<DocumentUploadModal
linkId={linkId}
dataroomId={dataroom?.id}
viewerId={viewerId}
folderId={folderId ?? undefined}
/>
)}
</div>
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="h-4 w-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
</Sheet>
</div>
</div>
</div>
{/* Search results banner */}
{searchQuery && (
<div className="mt-4 rounded-md border border-muted/50 bg-muted px-4 py-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-muted-foreground">
Search results for &quot;{searchQuery}&quot;
</div>
<div className="text-xs text-muted-foreground">
({mixedItems.length} result
{mixedItems.length !== 1 ? "s" : ""} across all folders)
<div className="flex flex-1 items-center justify-between gap-x-2">
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem key={"root"}>
<BreadcrumbLink
onClick={() => setFolderId(null)}
className="cursor-pointer"
>
Home
</BreadcrumbLink>
</BreadcrumbItem>
{breadcrumbFolders.map((folder, index) => (
<React.Fragment key={folder.id}>
<BreadcrumbSeparator />
<BreadcrumbItem>
<ViewerBreadcrumbItem
folder={folder}
setFolderId={setFolderId}
isLast={index === breadcrumbFolders.length - 1}
dataroomIndexEnabled={dataroomIndexEnabled}
/>
</BreadcrumbItem>
</React.Fragment>
))}
</BreadcrumbList>
</Breadcrumb>
<div className="flex items-center gap-x-2">
<SearchBoxPersisted inputClassName="h-9" />
{enableIndexFile && viewId && viewerId && (
<IndexFileDialog
linkId={linkId}
viewId={viewId}
dataroomId={dataroom?.id}
viewerId={viewerId}
viewerEmail={viewerEmail}
/>
)}
{viewData?.enableVisitorUpload && viewerId && (
<DocumentUploadModal
linkId={linkId}
dataroomId={dataroom?.id}
viewerId={viewerId}
folderId={folderId ?? undefined}
/>
)}
</div>
</div>
</div>
)}
<ul role="list" className="-mx-4 space-y-4 overflow-auto p-4">
{mixedItems.length === 0 ? (
<li className="py-6 text-center text-muted-foreground">
{searchQuery
? "No documents match your search."
: "No items available."}
</li>
) : (
mixedItems.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))
{/* Search results banner */}
{searchQuery && (
<div className="mt-4 rounded-md border border-muted/50 bg-muted px-4 py-3">
<div className="flex items-center gap-2">
<div className="text-sm font-medium text-muted-foreground">
Search results for &quot;{searchQuery}&quot;
</div>
<div className="text-xs text-muted-foreground">
({mixedItems.length} result
{mixedItems.length !== 1 ? "s" : ""} across all folders)
</div>
</div>
</div>
)}
</ul>
</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
<ul role="list" className="-mx-4 space-y-4 overflow-auto p-4">
{mixedItems.length === 0 ? (
<li className="py-6 text-center text-muted-foreground">
{searchQuery
? "No documents match your search."
: "No items available."}
</li>
) : (
mixedItems.map((item) => (
<li key={item.id}>{renderItem(item)}</li>
))
)}
</ul>
</div>
<ScrollBar orientation="vertical" />
<ScrollBar orientation="horizontal" />
</ScrollArea>
</div>
</div>
</div>
</>
</ViewerChatLayout>
{/* AI Chat Components */}
<ViewerChatPanel />
<ViewerChatToggle />
</ViewerChatProvider>
);
}
+10
View File
@@ -94,6 +94,16 @@ export default function ImageViewer({
// Add keyboard shortcuts for zooming and fullscreen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs or textareas
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (e.metaKey || e.ctrlKey) {
if (e.key === "=" || e.key === "+") {
e.preventDefault();
@@ -570,6 +570,16 @@ export default function PagesHorizontalViewer({
// Add keyboard shortcuts for zooming and fullscreen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs or textareas
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (e.metaKey || e.ctrlKey) {
if (e.key === "=" || e.key === "+") {
e.preventDefault();
@@ -742,6 +742,16 @@ export default function PagesVerticalViewer({
// Add keyboard shortcuts for zooming and fullscreen
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Don't trigger shortcuts when typing in inputs or textareas
const target = e.target as HTMLElement;
if (
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable
) {
return;
}
if (e.metaKey || e.ctrlKey) {
if (e.key === "=" || e.key === "+") {
e.preventDefault();
+1 -1
View File
@@ -1,5 +1,5 @@
The Papermark Commercial License (the “Commercial License”)
Copyright (c) 2024-present Papermark, Inc.
Copyright (c) 2023-present Papermark, Inc.
With regard to the Papermark Software:
+16
View File
@@ -0,0 +1,16 @@
<div align="center">
<h1 align="center">Papermark</h1>
<a href="https://www.papermark.com/enterprise">Get an Enterprise License</a>
</div>
# Enterprise Edition
Welcome to the Enterprise Edition of Papermark.
The [/(ee)](https://github.com/mfts/papermark/tree/main/ee) subfolder is the place for all the **Enterprise Edition** features from our [hosted](https://www.papermark.com/pricing) plan and enterprise-grade features for [Enterprise](https://www.papermark.com/enterprise), included but not limited to the following:
- Data Rooms
- Q&A Conversations
- Advanced Permissions
> _❗ WARNING: This repository is copyrighted. You are not allowed to use this code to host your own version of app.papermark.com without obtaining a proper [license](https://www.papermark.com/enterprise) first❗_
@@ -0,0 +1,236 @@
"use client";
import { useState } from "react";
import { Bot, Loader2 } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
interface AgentsSettingsCardProps {
type: "document" | "dataroom" | "team";
entityId: string;
teamId: string;
agentsEnabled: boolean;
vectorStoreId?: string | null;
onUpdate?: () => void;
}
export function AgentsSettingsCard({
type,
entityId,
teamId,
agentsEnabled: initialEnabled,
vectorStoreId,
onUpdate,
}: AgentsSettingsCardProps) {
const [agentsEnabled, setAgentsEnabled] = useState(initialEnabled);
const [loading, setLoading] = useState(false);
const [indexing, setIndexing] = useState(false);
// Check if AI feature is enabled for this team
const { isFeatureEnabled, isLoading: featuresLoading } = useFeatureFlags();
const isAIFeatureEnabled = isFeatureEnabled("ai");
// Don't render if feature flags are still loading
if (featuresLoading) {
return (
<Card>
<CardContent className="flex h-32 items-center justify-center">
<LoadingSpinner className="h-6 w-6" />
</CardContent>
</Card>
);
}
// Don't render if AI feature is not enabled for this team
if (!isAIFeatureEnabled) {
return null;
}
const handleToggleAgents = async (enabled: boolean) => {
setLoading(true);
try {
const endpoint =
type === "document"
? `/api/teams/${teamId}/documents/${entityId}`
: type === "dataroom"
? `/api/teams/${teamId}/datarooms/${entityId}`
: `/api/teams/${teamId}`;
const response = await fetch(endpoint, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ agentsEnabled: enabled }),
});
if (!response.ok) {
throw new Error("Failed to update agents setting");
}
setAgentsEnabled(enabled);
toast.success(`AI Agents ${enabled ? "enabled" : "disabled"}`);
// Mutate the relevant SWR cache
if (type === "document") {
await mutate(`/api/teams/${teamId}/documents/${entityId}`);
} else if (type === "dataroom") {
await mutate(`/api/teams/${teamId}/datarooms/${entityId}`);
}
onUpdate?.();
} catch (error) {
console.error("Error toggling agents:", error);
toast.error("Failed to update agents setting");
setAgentsEnabled(!enabled); // Revert on error
} finally {
setLoading(false);
}
};
const handleIndexDocuments = async () => {
setIndexing(true);
try {
const endpoint =
type === "document"
? `/api/ai/store/teams/${teamId}/documents/${entityId}`
: `/api/ai/store/teams/${teamId}/datarooms/${entityId}`;
const response = await fetch(endpoint, {
method: "POST",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to index documents");
}
const data = await response.json();
if (data.errors && data.errors.length > 0) {
toast.warning(
`Indexed ${data.filesIndexed}/${data.totalDocuments || "N/A"} documents with some errors`,
);
} else {
toast.success(
`Successfully indexed ${type === "document" ? "document" : `${data.filesIndexed} documents`}`,
);
}
onUpdate?.();
} catch (error: any) {
console.error("Error indexing documents:", error);
toast.error(error.message || "Failed to index documents");
} finally {
setIndexing(false);
}
};
const isIndexed = type === "document" ? !!vectorStoreId : !!vectorStoreId;
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
<CardTitle>AI Agents</CardTitle>
</div>
<span
className="relative ml-auto flex h-4 w-4"
title={`AI Agents are ${agentsEnabled ? "" : "not"} enabled`}
>
<span
className={cn(
"absolute inline-flex h-full w-full rounded-full opacity-75",
agentsEnabled ? "animate-ping bg-green-400" : "",
)}
/>
<span
className={cn(
"relative inline-flex h-4 w-4 rounded-full",
agentsEnabled ? "bg-green-500" : "bg-gray-400",
)}
/>
</span>
</div>
<CardDescription>
Enable AI-powered chat to let visitors ask questions about{" "}
{type === "document" ? "this document" : "documents in this " + type}.
Documents must be indexed for chat to work.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="agents-enabled" className="flex flex-col space-y-1">
<span>Enable AI Agents</span>
<span className="text-xs font-normal leading-snug text-muted-foreground">
Allow visitors to chat with AI about{" "}
{type === "document" ? "this document" : "these documents"}
</span>
</Label>
<Switch
id="agents-enabled"
checked={agentsEnabled}
onCheckedChange={handleToggleAgents}
disabled={loading}
/>
</div>
{agentsEnabled && (
<div className="flex items-center justify-between space-x-2 border-t pt-4">
<div className="flex flex-col space-y-1">
<span className="text-sm font-medium">Index Status</span>
<span className="text-xs text-muted-foreground">
{isIndexed
? `${type === "document" ? "Document" : "Documents"} indexed and ready`
: `${type === "document" ? "Document needs" : "Documents need"} to be indexed`}
</span>
</div>
<Button
onClick={handleIndexDocuments}
disabled={indexing}
size="sm"
>
{indexing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Indexing...
</>
) : (
<>Index {type === "document" ? "Document" : "Documents"}</>
)}
</Button>
</div>
)}
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3">
<p className="text-sm text-muted-foreground transition-colors">
AI Agents use OpenAI to answer questions. Only PDF documents are
currently supported.
</p>
</CardFooter>
</Card>
);
}
+156
View File
@@ -0,0 +1,156 @@
"use client";
import { memo, useState } from "react";
import {
CheckIcon,
CopyIcon,
ThumbsDownIcon,
ThumbsUpIcon,
} from "lucide-react";
import {
Message,
MessageAction,
MessageActions,
MessageContent,
MessageResponse,
} from "@/components/ai-elements/message";
import { Shimmer } from "@/components/ai-elements/shimmer";
import type { FileCitation } from "../lib/stream/parse-ui-message-stream";
// ============================================================================
// Types
// ============================================================================
export interface ChatMessageProps {
role: "user" | "assistant";
content: string;
citations?: FileCitation[];
isStreaming?: boolean;
className?: string;
messageId?: string;
onFeedback?: (messageId: string, feedback: "up" | "down") => void;
}
type FeedbackState = "up" | "down" | null;
// ============================================================================
// Main Component
// ============================================================================
export const ChatMessage = memo(function ChatMessage({
role,
content,
isStreaming = false,
className,
messageId,
onFeedback,
}: ChatMessageProps) {
const [copied, setCopied] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>(null);
const isUser = role === "user";
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleFeedback = (type: "up" | "down") => {
// Toggle feedback if clicking the same button, otherwise set new feedback
const newFeedback = feedback === type ? null : type;
setFeedback(newFeedback);
if (messageId && onFeedback && newFeedback) {
onFeedback(messageId, newFeedback);
}
};
// For user messages, simple rendering
if (isUser) {
return (
<Message from="user" className={className}>
<MessageContent>
<p className="whitespace-pre-wrap">{content}</p>
</MessageContent>
</Message>
);
}
// For assistant messages
return (
<div className="space-y-1">
<Message from="assistant" className={className}>
<MessageContent>
{isStreaming && !content ? (
<Shimmer duration={1.5}>Generating response...</Shimmer>
) : (
<div className="prose prose-sm max-w-none dark:prose-invert">
<MessageResponse>{content}</MessageResponse>
</div>
)}
</MessageContent>
</Message>
{/* Message actions - always visible when there's content and not streaming */}
{content && !isStreaming && (
<MessageActions className="ml-0">
<MessageAction
onClick={handleCopy}
tooltip={copied ? "Copied!" : "Copy"}
label={copied ? "Copied" : "Copy message"}
variant="ghost"
size="icon"
className="size-7"
>
{copied ? (
<CheckIcon className="size-3.5 text-green-500" />
) : (
<CopyIcon className="size-3.5 text-muted-foreground" />
)}
</MessageAction>
{/* <MessageAction
onClick={() => handleFeedback("up")}
tooltip="Good response"
label="Good response"
variant="ghost"
size="icon"
className="size-7"
>
<ThumbsUpIcon
className={`size-3.5 ${
feedback === "up"
? "fill-green-500 text-green-500"
: "text-muted-foreground"
}`}
/>
</MessageAction>
<MessageAction
onClick={() => handleFeedback("down")}
tooltip="Bad response"
label="Bad response"
variant="ghost"
size="icon"
className="size-7"
>
<ThumbsDownIcon
className={`size-3.5 ${
feedback === "down"
? "fill-red-500 text-red-500"
: "text-muted-foreground"
}`}
/>
</MessageAction> */}
</MessageActions>
)}
</div>
);
});
export default ChatMessage;
@@ -0,0 +1,228 @@
"use client";
import { useState } from "react";
import { Bot, ExternalLink, Loader2, Shield } from "lucide-react";
import { toast } from "sonner";
import { mutate } from "swr";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
interface DocumentAIDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
documentId: string;
teamId: string;
agentsEnabled: boolean;
vectorStoreFileId?: string | null;
}
export function DocumentAIDialog({
open,
onOpenChange,
documentId,
teamId,
agentsEnabled: initialEnabled,
vectorStoreFileId,
}: DocumentAIDialogProps) {
const { isFeatureEnabled } = useFeatureFlags();
const isAIFeatureEnabled = isFeatureEnabled("ai");
const [agentsEnabled, setAgentsEnabled] = useState(initialEnabled);
const [loading, setLoading] = useState(false);
const [indexing, setIndexing] = useState(false);
// Don't render if AI feature is not enabled
if (!isAIFeatureEnabled) {
return null;
}
const handleToggleAgents = async (enabled: boolean) => {
setLoading(true);
try {
const response = await fetch(
`/api/teams/${teamId}/documents/${documentId}`,
{
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ agentsEnabled: enabled }),
},
);
if (!response.ok) {
throw new Error("Failed to update agents setting");
}
setAgentsEnabled(enabled);
toast.success(`AI Agents ${enabled ? "enabled" : "disabled"}`);
// Mutate the document cache
await mutate(`/api/teams/${teamId}/documents/${documentId}`);
} catch (error) {
console.error("Error toggling agents:", error);
toast.error("Failed to update agents setting");
setAgentsEnabled(!enabled);
} finally {
setLoading(false);
}
};
const handleIndexDocument = async () => {
setIndexing(true);
try {
const response = await fetch(
`/api/ai/store/teams/${teamId}/documents/${documentId}`,
{
method: "POST",
},
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to index document");
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
toast.success("Document indexed successfully");
// Mutate the document cache
await mutate(`/api/teams/${teamId}/documents/${documentId}`);
} catch (error: any) {
console.error("Error indexing document:", error);
toast.error(error.message || "Failed to index document");
} finally {
setIndexing(false);
}
};
const isIndexed = !!vectorStoreFileId;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
AI Agents
</DialogTitle>
<DialogDescription>
Enable AI-powered chat for this document. Visitors can ask questions
and get intelligent answers.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Toggle Section */}
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<Label htmlFor="agents-toggle" className="flex flex-col space-y-1">
<span className="font-medium">Enable AI Chat</span>
<span className="text-xs font-normal leading-snug text-muted-foreground">
Allow visitors to chat with AI about this document
</span>
</Label>
<Switch
id="agents-toggle"
checked={agentsEnabled}
onCheckedChange={handleToggleAgents}
disabled={loading}
/>
</div>
{/* Index Status */}
{agentsEnabled && (
<div className="flex items-center justify-between space-x-2 rounded-lg border p-4">
<div className="flex flex-col space-y-1">
<span className="text-sm font-medium">Index Status</span>
<span className="text-xs text-muted-foreground">
{isIndexed
? "Document is indexed and ready for AI chat"
: "Document needs to be indexed before AI chat can work"}
</span>
</div>
<div className="flex items-center gap-2">
<span
className={cn(
"inline-flex h-2 w-2 rounded-full",
isIndexed ? "bg-green-500" : "bg-amber-500",
)}
/>
<Button
onClick={handleIndexDocument}
disabled={indexing}
size="sm"
variant={isIndexed ? "outline" : "default"}
>
{indexing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Indexing...
</>
) : isIndexed ? (
"Re-index"
) : (
"Index Document"
)}
</Button>
</div>
</div>
)}
{/* Privacy Notice */}
<div className="rounded-lg border border-green-200 bg-green-50 p-4 dark:border-green-900 dark:bg-green-950/30">
<div className="flex items-start gap-3">
<Shield className="mt-0.5 h-5 w-5 shrink-0 text-green-600" />
<div className="space-y-2 text-sm">
<p className="font-medium text-green-900 dark:text-green-100">
Privacy & Data Usage
</p>
<ul className="space-y-1 text-green-800 dark:text-green-200">
<li> Powered by OpenAI&apos;s API</li>
<li> Your data is NOT used to train AI models</li>
<li> Document embeddings stored securely</li>
<li> Delete anytime by disabling AI</li>
</ul>
<a
href="https://openai.com/policies/api-data-usage-policies"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-xs text-green-700 hover:underline dark:text-green-300"
>
OpenAI Data Usage Policy
<ExternalLink className="h-3 w-3" />
</a>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,417 @@
"use client";
import { useCallback, useState } from "react";
import { FileTextIcon, Loader2, Plus, SparklesIcon, XIcon } from "lucide-react";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
PromptInput,
PromptInputFooter,
PromptInputHeader,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseTextStream } from "../lib/stream/parse-text-stream";
import ChatMessage from "./chat-message";
import { useViewerChatSafe } from "./viewer-chat-provider";
import { ViewerThreadSelector } from "./viewer-thread-selector";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
isStreaming?: boolean;
}
interface ViewerChatPanelProps {
className?: string;
}
/**
* Standalone chat panel that reads configuration from ViewerChatProvider.
* Place this anywhere in the component tree within ViewerChatProvider.
* It will render as a fixed panel on the right side when open.
*/
export function ViewerChatPanel({ className }: ViewerChatPanelProps) {
const context = useViewerChatSafe();
// Don't render if not in provider or not enabled
if (!context || !context.isEnabled) {
return null;
}
// Don't render if closed
if (!context.isOpen) {
return null;
}
return (
<div className="fixed bottom-0 right-0 top-16 z-30 w-[400px] shadow-xl">
<ViewerChatPanelContent
onClose={context.close}
dataroomId={context.config.dataroomId}
dataroomName={context.config.dataroomName}
documentId={context.config.documentId}
documentName={context.config.documentName}
linkId={context.config.linkId}
viewId={context.config.viewId}
viewerId={context.config.viewerId}
/>
</div>
);
}
// ============================================================================
// Internal Content Component
// ============================================================================
interface ViewerChatPanelContentProps {
onClose: () => void;
dataroomId?: string;
dataroomName?: string;
documentId?: string;
documentName?: string;
linkId?: string;
viewId?: string;
viewerId?: string;
}
function ViewerChatPanelContent({
onClose,
dataroomId,
dataroomName,
documentId,
documentName,
linkId,
viewId,
viewerId,
}: ViewerChatPanelContentProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [chatId, setChatId] = useState<string | null>(null);
const [chatTitle, setChatTitle] = useState<string | undefined>();
const contextName = dataroomName || documentName || "Document";
// Create chat session
const createChat = async () => {
try {
const response = await fetch(`/api/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
dataroomId,
linkId,
viewId,
viewerId,
}),
});
if (!response.ok) throw new Error("Failed to create chat");
const data = await response.json();
setChatId(data.id);
setChatTitle(undefined);
return data.id;
} catch (error) {
console.error("Error creating chat:", error);
return null;
}
};
// Load existing chat
const loadChat = useCallback(
async (selectedChatId: string) => {
try {
setIsLoading(true);
const queryParams = viewerId ? `?viewerId=${viewerId}` : "";
const response = await fetch(
`/api/ai/chat/${selectedChatId}${queryParams}`,
);
if (!response.ok) throw new Error("Failed to load chat");
const data = await response.json();
setChatId(selectedChatId);
setChatTitle(data.title);
// Convert messages to the format expected by the UI
const loadedMessages: Message[] = data.messages.map((msg: any) => ({
id: msg.id,
role: msg.role,
content: msg.content,
}));
setMessages(loadedMessages);
} catch (error) {
console.error("Error loading chat:", error);
} finally {
setIsLoading(false);
}
},
[viewerId],
);
// Start new chat
const handleNewChat = useCallback(() => {
setChatId(null);
setChatTitle(undefined);
setMessages([]);
}, []);
// Delete chat
const handleDeleteChat = useCallback(
async (deleteChatId: string) => {
try {
const queryParams = viewerId ? `?viewerId=${viewerId}` : "";
await fetch(`/api/ai/chat/${deleteChatId}${queryParams}`, {
method: "DELETE",
});
// If deleted current chat, start new one
if (deleteChatId === chatId) {
handleNewChat();
}
} catch (error) {
console.error("Error deleting chat:", error);
}
},
[chatId, viewerId, handleNewChat],
);
const handleSubmit = async (message: PromptInputMessage) => {
if (!message.text.trim() || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: message.text.trim(),
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
try {
// Create chat if needed
let currentChatId = chatId;
if (!currentChatId) {
currentChatId = await createChat();
if (!currentChatId) {
setIsLoading(false);
return;
}
}
const queryParams = viewerId ? `?viewerId=${viewerId}` : "";
const response = await fetch(
`/api/ai/chat/${currentChatId}/messages${queryParams}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: userMessage.content,
// When viewing a specific document in a dataroom, filter search to that document
filterDocumentId: documentId,
}),
},
);
if (!response.ok) throw new Error("Failed to send message");
// Get the reader for streaming
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
// Create placeholder for assistant message
const assistantMessageId = `assistant-${Date.now()}`;
const assistantMessage: Message = {
id: assistantMessageId,
role: "assistant",
content: "",
isStreaming: true,
};
setMessages((prev) => [...prev, assistantMessage]);
// Parse the text stream
await parseTextStream(reader, {
onTextDelta: (_delta, accumulated) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId ? { ...m, content: accumulated } : m,
),
);
},
onTextEnd: (content) => {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId
? { ...m, content, isStreaming: false }
: m,
),
);
},
onError: (error) => {
console.error("Stream error:", error);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId
? {
...m,
content:
"Sorry, there was an error processing your request.",
isStreaming: false,
}
: m,
),
);
},
});
} catch (error) {
console.error("Error sending message:", error);
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
role: "assistant",
content: "Sorry, there was an error sending your message.",
},
]);
} finally {
setIsLoading(false);
}
};
return (
<div className="flex h-full w-full flex-col border-l border-gray-200 bg-white">
{/* Header with Thread Selector and New Chat Button */}
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ViewerThreadSelector
currentChatId={chatId}
currentChatTitle={chatTitle}
onSelectChat={loadChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
documentId={documentId}
dataroomId={dataroomId}
linkId={linkId}
viewerId={viewerId}
viewId={viewId}
/>
</div>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleNewChat}
className="size-8 p-0 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="size-8 p-0 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
>
<XIcon className="size-4" />
</Button>
</div>
</div>
{/* Context Name */}
<div className="border-b border-gray-100 bg-gray-50/50 px-4 py-1.5">
<p className="truncate text-xs text-gray-500">{contextName}</p>
</div>
{/* Messages */}
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-4">
{messages.length === 0 ? (
<ConversationEmptyState
icon={
<div className="flex size-12 items-center justify-center rounded-full bg-primary/10">
<SparklesIcon className="size-6 text-primary" />
</div>
}
title="Ask me anything"
description={`I can help you understand and analyze the content of this ${dataroomId ? "data room" : "document"}.`}
/>
) : (
<>
{messages.map((message) => (
<div key={message.id} className="space-y-2">
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
/>
</div>
))}
{/* Show loading indicator while waiting for response */}
{isLoading && messages[messages.length - 1]?.role === "user" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Thinking...</span>
</div>
)}
</>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Input */}
<div className="border-t border-gray-200 p-3">
<PromptInput onSubmit={handleSubmit}>
{/* Show focused document as an attachment when in dataroom */}
{documentId && documentName && (
<PromptInputHeader>
<div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/50 px-2 py-1 text-xs text-muted-foreground">
<FileTextIcon className="size-3" />
<span className="max-w-[200px] truncate">{documentName}</span>
</div>
</PromptInputHeader>
)}
<PromptInputTextarea
placeholder="Ask a question..."
disabled={isLoading}
className="min-h-12"
/>
<PromptInputFooter className="justify-end pt-2">
<PromptInputSubmit
disabled={isLoading}
status={isLoading ? "streaming" : "ready"}
/>
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
}
@@ -0,0 +1,183 @@
"use client";
import {
type ReactNode,
createContext,
useCallback,
useContext,
useState,
} from "react";
import { cn } from "@/lib/utils";
// ============================================================================
// Types
// ============================================================================
interface ViewerChatConfig {
dataroomId?: string;
dataroomName?: string;
documentId?: string;
documentName?: string;
linkId?: string;
viewId?: string;
viewerId?: string;
}
// interface FocusedDocument {
// id: string;
// name: string;
// }
interface ViewerChatContextType {
// State
isOpen: boolean;
isEnabled: boolean;
config: ViewerChatConfig;
// focusedDocument: FocusedDocument | null;
// Actions
open: () => void;
close: () => void;
toggle: () => void;
// setFocusedDocument: (doc: FocusedDocument | null) => void;
}
// ============================================================================
// Context
// ============================================================================
const ViewerChatContext = createContext<ViewerChatContextType | null>(null);
/**
* Hook to access chat state and actions.
* Throws if used outside ViewerChatProvider.
*/
export function useViewerChat() {
const context = useContext(ViewerChatContext);
if (!context) {
throw new Error("useViewerChat must be used within ViewerChatProvider");
}
return context;
}
/**
* Safe hook that returns null if not within ViewerChatProvider.
* Useful for components that may or may not be within a chat context.
*/
export function useViewerChatSafe() {
return useContext(ViewerChatContext);
}
// ============================================================================
// Provider
// ============================================================================
interface ViewerChatProviderProps {
children: ReactNode;
enabled?: boolean;
dataroomId?: string;
dataroomName?: string;
documentId?: string;
documentName?: string;
linkId?: string;
viewId?: string;
viewerId?: string;
// /** For dataroom document views - the currently focused document */
// focusedDocumentId?: string;
// focusedDocumentName?: string;
}
export function ViewerChatProvider({
children,
enabled = false,
dataroomId,
dataroomName,
documentId,
documentName,
linkId,
viewId,
viewerId,
// focusedDocumentId,
// focusedDocumentName,
}: ViewerChatProviderProps) {
const [isOpen, setIsOpen] = useState(false);
// const [focusedDocument, setFocusedDocumentState] =
// useState<FocusedDocument | null>(
// focusedDocumentId && focusedDocumentName
// ? { id: focusedDocumentId, name: focusedDocumentName }
// : null,
// );
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => setIsOpen(false), []);
const toggle = useCallback(() => setIsOpen((prev) => !prev), []);
// const setFocusedDocument = useCallback(
// (doc: FocusedDocument | null) => setFocusedDocumentState(doc),
// [],
// );
const config: ViewerChatConfig = {
dataroomId,
dataroomName,
documentId,
documentName,
linkId,
viewId,
viewerId,
};
const value: ViewerChatContextType = {
isOpen,
isEnabled: enabled,
config,
// focusedDocument,
open,
close,
toggle,
// setFocusedDocument,
};
return (
<ViewerChatContext.Provider value={value}>
{children}
</ViewerChatContext.Provider>
);
}
// ============================================================================
// Layout Component - Applies padding when chat is open
// ============================================================================
interface ViewerChatLayoutProps {
children: ReactNode;
className?: string;
}
/**
* Layout wrapper that applies padding when chat panel is open.
* Use this to wrap content that should shrink when chat opens.
*/
export function ViewerChatLayout({
children,
className,
}: ViewerChatLayoutProps) {
const context = useViewerChatSafe();
// If not in provider or not enabled, just render children
if (!context || !context.isEnabled) {
return <>{children}</>;
}
return (
<div
className={cn(
"transition-all duration-300",
context.isOpen && "pr-[400px]",
className,
)}
>
{children}
</div>
);
}
@@ -0,0 +1,37 @@
"use client";
import { MessageSquare } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useViewerChatSafe } from "./viewer-chat-provider";
interface ViewerChatToggleProps {
className?: string;
}
/**
* Floating toggle button for the chat panel.
* Only renders when chat is enabled and closed.
* Place this anywhere in the component tree within ViewerChatProvider.
*/
export function ViewerChatToggle({ className }: ViewerChatToggleProps) {
const context = useViewerChatSafe();
// Don't render if not in provider, not enabled, or already open
if (!context || !context.isEnabled || context.isOpen) {
return null;
}
return (
<Button
onClick={context.open}
className="fixed bottom-4 right-4 z-40 gap-2 rounded-full shadow-lg"
size="lg"
>
<MessageSquare className="size-5" />
AI Chat
</Button>
);
}
@@ -0,0 +1,260 @@
"use client";
import { useMemo } from "react";
import {
differenceInDays,
format,
isThisWeek,
isToday,
startOfDay,
} from "date-fns";
import {
ChevronDown,
Loader2,
MessageSquare,
Plus,
Trash2,
} from "lucide-react";
import useSWR from "swr";
import { cn, fetcher } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
interface Chat {
id: string;
title: string | null;
createdAt: string;
lastMessageAt: string | null;
messages?: { content: string }[];
}
interface GroupedChats {
today: Chat[];
thisWeek: Chat[];
older: Chat[];
}
interface ViewerThreadSelectorProps {
currentChatId: string | null;
currentChatTitle?: string;
onSelectChat: (chatId: string) => void;
onNewChat: () => void;
onDeleteChat?: (chatId: string) => void;
documentId?: string;
dataroomId?: string;
linkId?: string;
viewerId?: string;
viewId?: string;
}
function groupChatsByDate(chats: Chat[]): GroupedChats {
const grouped: GroupedChats = {
today: [],
thisWeek: [],
older: [],
};
const now = new Date();
chats.forEach((chat) => {
const chatDate = new Date(chat.lastMessageAt || chat.createdAt);
if (isToday(chatDate)) {
grouped.today.push(chat);
} else if (isThisWeek(chatDate, { weekStartsOn: 1 })) {
grouped.thisWeek.push(chat);
} else {
grouped.older.push(chat);
}
});
return grouped;
}
function getChatDisplayTitle(chat: Chat): string {
if (chat.title) return chat.title;
if (chat.messages?.[0]?.content) {
const preview = chat.messages[0].content.slice(0, 40);
return preview.length < chat.messages[0].content.length
? `${preview}...`
: preview;
}
return "New Chat";
}
export function ViewerThreadSelector({
currentChatId,
currentChatTitle,
onSelectChat,
onNewChat,
onDeleteChat,
documentId,
dataroomId,
linkId,
viewerId,
viewId,
}: ViewerThreadSelectorProps) {
// Build query params for viewer-based chat listing
const params = new URLSearchParams();
if (viewerId) params.append("viewerId", viewerId);
if (documentId) params.append("documentId", documentId);
if (dataroomId) params.append("dataroomId", dataroomId);
if (linkId) params.append("linkId", linkId);
if (viewId) params.append("viewId", viewId);
const shouldFetch = viewerId && (documentId || dataroomId);
const {
data: chats,
isLoading,
mutate,
} = useSWR<Chat[]>(
shouldFetch ? `/api/ai/chat?${params.toString()}` : null,
fetcher,
);
const groupedChats = useMemo(() => {
if (!chats) return { today: [], thisWeek: [], older: [] };
return groupChatsByDate(chats);
}, [chats]);
const currentChat = chats?.find((chat) => chat.id === currentChatId);
const displayTitle =
currentChatTitle ||
getChatDisplayTitle(currentChat || ({} as Chat)) ||
"New Chat";
const handleDeleteChat = async (chatId: string, e: React.MouseEvent) => {
e.stopPropagation();
if (onDeleteChat) {
onDeleteChat(chatId);
}
// If deleting current chat, trigger new chat
if (chatId === currentChatId) {
onNewChat();
}
// Optimistically update the list
mutate(
chats?.filter((c) => c.id !== chatId),
false,
);
};
const renderChatItem = (chat: Chat) => (
<DropdownMenuItem
key={chat.id}
onClick={() => onSelectChat(chat.id)}
className={cn(
"group flex cursor-pointer items-center justify-between gap-2",
chat.id === currentChatId && "bg-gray-100",
)}
>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="truncate text-sm font-medium">
{getChatDisplayTitle(chat)}
</p>
<p className="text-xs text-gray-500">
{format(
new Date(chat.lastMessageAt || chat.createdAt),
"MMM d, h:mm a",
)}
</p>
</div>
{onDeleteChat && (
<Button
variant="ghost"
size="sm"
className="size-6 p-0 opacity-0 group-hover:opacity-100"
onClick={(e) => handleDeleteChat(chat.id, e)}
>
<Trash2 className="size-3.5 text-gray-500 hover:text-red-500" />
</Button>
)}
</DropdownMenuItem>
);
const hasChats =
groupedChats.today.length > 0 ||
groupedChats.thisWeek.length > 0 ||
groupedChats.older.length > 0;
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 max-w-[180px] gap-1.5 px-2 hover:bg-gray-100"
>
<MessageSquare className="size-4 shrink-0" />
<span className="truncate text-sm font-medium">{displayTitle}</span>
<ChevronDown className="size-3 shrink-0 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-full">
<div className="max-h-[400px] overflow-y-auto">
{hasChats && !isLoading ? (
<>
{/* Today */}
{groupedChats.today.length > 0 && (
<>
<DropdownMenuLabel className="text-xs font-medium text-gray-500">
Today
</DropdownMenuLabel>
{groupedChats.today.map(renderChatItem)}
</>
)}
{/* This Week */}
{groupedChats.thisWeek.length > 0 && (
<>
{groupedChats.today.length > 0 && <DropdownMenuSeparator />}
<DropdownMenuLabel className="text-xs font-medium text-gray-500">
This Week
</DropdownMenuLabel>
{groupedChats.thisWeek.map(renderChatItem)}
</>
)}
{/* Older */}
{groupedChats.older.length > 0 && (
<>
{(groupedChats.today.length > 0 ||
groupedChats.thisWeek.length > 0) && (
<DropdownMenuSeparator />
)}
<DropdownMenuLabel className="text-xs font-medium text-gray-500">
Older
</DropdownMenuLabel>
{groupedChats.older.map(renderChatItem)}
</>
)}
</>
) : isLoading ? (
<div className="flex items-center gap-2 p-4">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm text-gray-500">
Loading chat history...
</span>
</div>
) : (
<div className="px-2 py-4 text-center text-sm text-gray-500">
No chat history yet
</div>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
+57
View File
@@ -0,0 +1,57 @@
import prisma from "@/lib/prisma";
interface CreateChatOptions {
teamId: string;
documentId?: string;
dataroomId?: string;
linkId?: string;
viewId?: string;
userId?: string;
viewerId?: string;
vectorStoreId?: string;
title?: string;
}
/**
* Create a new chat session
* @param options - Chat creation options
* @returns The created chat
*/
export async function createChat(options: CreateChatOptions) {
const {
teamId,
documentId,
dataroomId,
linkId,
viewId,
userId,
viewerId,
vectorStoreId,
title,
} = options;
try {
const chat = await prisma.chat.create({
data: {
teamId,
documentId,
dataroomId,
linkId,
viewId,
userId,
viewerId,
vectorStoreId,
title,
},
include: {
messages: true,
},
});
return chat;
} catch (error) {
console.error("Error creating chat:", error);
throw new Error("Failed to create chat");
}
}
@@ -0,0 +1,36 @@
import { openai } from "@ai-sdk/openai";
import { generateText } from "ai";
/**
* Generate a chat title from the first message
* @param firstMessage - The first user message
* @returns Generated title
*/
export async function generateChatTitle(firstMessage: string): Promise<string> {
try {
// Limit message length for title generation
const truncatedMessage = firstMessage.slice(0, 200);
const { text } = await generateText({
model: openai("gpt-4o-mini"),
prompt: `Generate a short, descriptive title (max 6 words) for a chat that starts with this message: "${truncatedMessage}". Only return the title, nothing else.`,
providerOptions: {
openai: {
maxOutputTokens: 20,
},
},
});
// Clean up the title
const title = text
.trim()
.replace(/^["']|["']$/g, "") // Remove quotes
.slice(0, 60); // Max 60 chars
return title;
} catch (error) {
console.error("Error generating chat title:", error);
// Return a default title based on first few words
return firstMessage.slice(0, 50) + (firstMessage.length > 50 ? "..." : "");
}
}
@@ -0,0 +1,70 @@
import prisma from "@/lib/prisma";
/**
* Get filtered dataroom document IDs based on link permissions
* @param dataroomId - The dataroom ID
* @param linkId - The link ID (optional, for external visitors)
* @returns Array of accessible dataroom document IDs
*/
export async function getFilteredDataroomDocumentIds(
dataroomId: string,
linkId?: string,
): Promise<string[]> {
try {
// If external visitor with link, check permission groups
if (!linkId) {
return [];
}
const link = await prisma.link.findUnique({
where: { id: linkId, dataroomId },
select: {
permissionGroupId: true,
groupId: true,
},
});
if (!link) {
return [];
}
let accessibleDocuments: { itemId: string }[] = [];
if (link.permissionGroupId) {
accessibleDocuments = await prisma.permissionGroupAccessControls.findMany(
{
where: {
groupId: link.permissionGroupId,
itemType: "DATAROOM_DOCUMENT",
canView: true,
},
select: {
itemId: true,
},
},
);
}
if (link.groupId) {
accessibleDocuments = await prisma.viewerGroupAccessControls.findMany({
where: {
groupId: link.groupId,
itemType: "DATAROOM_DOCUMENT",
canView: true,
},
select: {
itemId: true,
},
});
}
const dataroomDocumentIds = accessibleDocuments.map(
(document) => document.itemId,
);
return dataroomDocumentIds;
} catch (error) {
console.error("Error getting filtered file IDs:", error);
throw new Error("Failed to get filtered file IDs");
}
}
+117
View File
@@ -0,0 +1,117 @@
import { OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai";
import { streamText } from "ai";
import prisma from "@/lib/prisma";
interface SendMessageOptions {
chatId: string;
content: string;
vectorStoreId: string;
filteredDataroomDocumentIds?: string[];
/** Filter file_search results to a specific document by its ID */
filterDocumentId?: string;
}
/**
* Send a message and get streaming response
* Uses AI SDK with OpenAI file_search tool
*/
export async function sendMessage({
chatId,
content,
vectorStoreId,
filteredDataroomDocumentIds,
filterDocumentId,
}: SendMessageOptions) {
// Get conversation history from database
const history = await prisma.chatMessage.findMany({
where: { chatId },
orderBy: { createdAt: "asc" },
take: 10,
});
// Save user message
await prisma.chatMessage.create({
data: { chatId, role: "user", content },
});
// Build messages for AI SDK
const messages = [
{
role: "system" as const,
content: `You are a helpful AI assistant answering questions about documents.
Use the file_search tool to find relevant information from the documents.
Always cite sources with document names and page numbers when providing information.
If you cannot find the answer in the documents, say so clearly.`,
},
...history.map((m) => ({
role: m.role as "user" | "assistant",
content: m.content,
})),
{ role: "user" as const, content },
];
// Build file_search tool options with optional document filter
const fileSearchOptions: Parameters<typeof openai.tools.fileSearch>[0] = {
vectorStoreIds: [vectorStoreId],
};
// Add document filter when viewing a specific document in a dataroom
if (filterDocumentId) {
fileSearchOptions.filters = {
type: "eq",
key: "documentId",
value: filterDocumentId,
};
}
if (filteredDataroomDocumentIds) {
fileSearchOptions.filters = {
type: "in",
key: "dataroomDocumentId",
value: filteredDataroomDocumentIds,
};
}
// Use AI SDK streamText with file_search tool
const result = streamText({
model: openai.responses("gpt-4o-mini"),
messages,
tools: {
file_search: openai.tools.fileSearch(fileSearchOptions),
},
providerOptions: {
openai: {
previousResponseId:
history.length > 0
? (history[history.length - 1].metadata as { responseId: string })
?.responseId
: null,
} satisfies OpenAIResponsesProviderOptions,
},
onFinish: async ({ text, usage, response }) => {
await Promise.all([
prisma.chatMessage.create({
data: {
chatId,
role: "assistant",
content: text,
metadata: {
usage,
vectorStoreId,
responseId: response.id,
modelId: response.modelId,
filters: fileSearchOptions.filters,
},
},
}),
prisma.chat.update({
where: { id: chatId },
data: { lastMessageAt: new Date() },
}),
]);
},
});
return result;
}
@@ -0,0 +1,63 @@
import prisma from "@/lib/prisma";
interface DocumentMetadata {
documentId: string;
documentName: string;
versionId: string;
dataroomId?: string;
folderId?: string;
teamId: string;
}
/**
* Extract metadata from a document for vector store tagging
* @param documentId - The document ID
* @param versionId - The document version ID
* @returns Document metadata
*/
export async function extractDocumentMetadata(
documentId: string,
versionId: string,
): Promise<DocumentMetadata> {
try {
const documentVersion = await prisma.documentVersion.findUnique({
where: { id: versionId },
include: {
document: {
include: {
datarooms: {
include: {
dataroom: true,
folder: true,
},
},
},
},
},
});
if (!documentVersion) {
throw new Error("Document version not found");
}
const document = documentVersion.document;
// Get dataroom and folder info if document is in a dataroom
const dataroomDocument = document.datarooms[0]; // Get first dataroom association
const metadata: DocumentMetadata = {
documentId: document.id,
documentName: document.name,
versionId: documentVersion.id,
teamId: document.teamId,
dataroomId: dataroomDocument?.dataroomId,
folderId: dataroomDocument?.folderId || document.folderId || undefined,
};
return metadata;
} catch (error) {
console.error("Error extracting document metadata:", error);
throw new Error("Failed to extract document metadata");
}
}
@@ -0,0 +1,51 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
import { DocumentStorageType } from "@prisma/client";
import { toFile } from "openai";
import path from "path";
import { getFile } from "@/lib/files/get-file";
/**
* Process a document and prepare it for vector store upload
* Downloads the file from storage and returns it as a buffer
* @param filePath - The file path/key in storage
* @param storageType - The storage type (S3_PATH or VERCEL_BLOB)
* @returns File ID
*/
export async function processDocumentForVectorStore(
filePath: string,
storageType: DocumentStorageType,
): Promise<{ fileId: string }> {
try {
// Get the file URL
const fileUrl = await getFile({
type: storageType,
data: filePath,
isDownload: true,
});
// Fetch the file
const response = await fetch(fileUrl);
if (!response.ok) {
throw new Error(`Failed to fetch file: ${response.statusText}`);
}
// Extract filename from the original file path (not the presigned URL)
const fileName = path.basename(filePath);
// Convert response to buffer and create a properly named file
// This prevents OpenAI from inferring extension from URL query params
// const buffer = await response.arrayBuffer();
const file = await toFile(response, fileName, { type: "application/pdf" });
const fileResponse = await openai.files.create({
file,
purpose: "assistants",
});
return { fileId: fileResponse.id };
} catch (error) {
console.error("Error processing document for vector store:", error);
throw new Error("Failed to process document for vector store");
}
}
+5
View File
@@ -0,0 +1,5 @@
import { OpenAI } from "openai";
export const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY,
});
@@ -0,0 +1,81 @@
import prisma from "@/lib/prisma";
interface ValidateChatAccessOptions {
chatId: string;
userId?: string;
viewerId?: string;
}
/**
* Validate if a user or viewer has access to a chat
* @param options - Chat ID and user/viewer ID
* @returns Boolean indicating if access is granted
*/
export async function validateChatAccess({
chatId,
userId,
viewerId,
}: ValidateChatAccessOptions): Promise<boolean> {
try {
const chat = await prisma.chat.findUnique({
where: { id: chatId },
select: {
teamId: true,
userId: true,
viewerId: true,
},
});
if (!chat) {
return false;
}
// Internal user access
if (userId) {
// Check if user is member of the team
const userTeam = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId,
teamId: chat.teamId,
},
},
});
if (!userTeam) {
return false;
}
// Additional check: if chat has a specific userId, it must match
if (chat.userId && chat.userId !== userId) {
return false;
}
return true;
}
// External viewer access
if (viewerId) {
// Check if viewer is associated with this chat
if (chat.viewerId !== viewerId) {
return false;
}
// Verify viewer exists and is associated with the team
const viewer = await prisma.viewer.findUnique({
where: { id: viewerId },
});
if (!viewer || viewer.teamId !== chat.teamId) {
return false;
}
return true;
}
return false;
} catch (error) {
console.error("Error validating chat access:", error);
return false;
}
}
@@ -0,0 +1,52 @@
/**
* Parser for AI SDK's toTextStreamResponse() format
* Handles plain text stream (no SSE, no JSON structure)
*/
export interface TextStreamCallbacks {
onTextDelta?: (delta: string, accumulated: string) => void;
onTextEnd?: (content: string) => void;
onError?: (error: Error) => void;
}
export interface TextStreamResult {
content: string;
}
/**
* Parse the text stream from toTextStreamResponse()
* This is a simple text-only stream with no structured events
*/
export async function parseTextStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
callbacks: TextStreamCallbacks,
): Promise<TextStreamResult> {
const decoder = new TextDecoder();
let content = "";
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
content += chunk;
callbacks.onTextDelta?.(chunk, content);
}
// Flush decoder
const remaining = decoder.decode();
if (remaining) {
content += remaining;
callbacks.onTextDelta?.(remaining, content);
}
} catch (error) {
callbacks.onError?.(error as Error);
throw error;
}
callbacks.onTextEnd?.(content);
return { content };
}
@@ -0,0 +1,34 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
/**
* Create a vector store for a dataroom
* @param dataroomId - The dataroom ID
* @param teamId - The team ID
* @param name - The name of the vector store
* @returns The vector store ID
*/
export async function createDataroomVectorStore({
dataroomId,
teamId,
name,
}: {
dataroomId: string;
teamId: string;
name: string;
}): Promise<string> {
try {
const vectorStore = await openai.vectorStores.create({
name: `Dataroom: ${name}`,
metadata: {
dataroomId,
teamId,
type: "dataroom",
},
});
return vectorStore.id;
} catch (error) {
console.error("Error creating dataroom vector store:", error);
throw new Error("Failed to create dataroom vector store");
}
}
@@ -0,0 +1,27 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
/**
* Create a vector store for a team
* @param teamId - The team ID
* @param name - The team name
* @returns The vector store ID
*/
export async function createTeamVectorStore(
teamId: string,
name: string,
): Promise<string> {
try {
const vectorStore = await openai.vectorStores.create({
name: `Team: ${name}`,
metadata: {
teamId,
type: "team",
},
});
return vectorStore.id;
} catch (error) {
console.error("Error creating team vector store:", error);
throw new Error("Failed to create team vector store");
}
}
@@ -0,0 +1,14 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
/**
* Delete a vector store
* @param vectorStoreId - The vector store ID to delete
*/
export async function deleteVectorStore(vectorStoreId: string): Promise<void> {
try {
await openai.vectorStores.delete(vectorStoreId);
} catch (error) {
console.error("Error deleting vector store:", error);
throw new Error("Failed to delete vector store");
}
}
@@ -0,0 +1,26 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
interface VectorStoreFile {
id: string;
object: string;
created_at: number;
vector_store_id: string;
status: string;
}
/**
* Get all files in a vector store
* @param vectorStoreId - The vector store ID
* @returns Array of vector store files
*/
export async function getVectorStoreFiles(
vectorStoreId: string,
): Promise<VectorStoreFile[]> {
try {
const files = await openai.vectorStores.files.list(vectorStoreId);
return files.data as VectorStoreFile[];
} catch (error) {
console.error("Error getting vector store files:", error);
throw new Error("Failed to get vector store files");
}
}
@@ -0,0 +1,32 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
interface VectorStoreInfo {
id: string;
name: string;
file_counts: {
in_progress: number;
completed: number;
failed: number;
cancelled: number;
total: number;
};
created_at: number;
metadata: Record<string, string>;
}
/**
* Get information about a vector store
* @param vectorStoreId - The vector store ID
* @returns Vector store information
*/
export async function getVectorStoreInfo(
vectorStoreId: string,
): Promise<VectorStoreInfo> {
try {
const vectorStore = await openai.vectorStores.retrieve(vectorStoreId);
return vectorStore as VectorStoreInfo;
} catch (error) {
console.error("Error getting vector store info:", error);
throw new Error("Failed to get vector store info");
}
}
@@ -0,0 +1,20 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
/**
* Remove a file from a vector store
* @param vectorStoreId - The vector store ID
* @param fileId - The file ID to remove
*/
export async function removeFileFromVectorStore(
vectorStoreId: string,
fileId: string,
): Promise<void> {
try {
await openai.vectorStores.files.delete(fileId, {
vector_store_id: vectorStoreId,
});
} catch (error) {
console.error("Error removing file from vector store:", error);
throw new Error("Failed to remove file from vector store");
}
}
@@ -0,0 +1,43 @@
import { openai } from "@/ee/features/ai/lib/models/openai";
interface VectorStoreFileOptions {
vectorStoreId: string;
fileId: string;
metadata: {
teamId: string;
documentId: string;
documentName: string;
versionId: string;
folderId?: string;
dataroomId?: string;
dataroomDocumentId?: string;
dataroomFolderId?: string;
};
}
/**
* Upload a file to a vector store
* @param options - Vector store file options including vector store ID, file ID, and metadata
* @returns The vector store file ID
*/
export async function addFileToVectorStore({
vectorStoreId,
fileId,
metadata,
}: VectorStoreFileOptions): Promise<string> {
try {
// Add file to vector store with metadata
const vectorStoreFile = await openai.vectorStores.files.create(
vectorStoreId,
{
file_id: fileId,
attributes: metadata,
},
);
return vectorStoreFile.id;
} catch (error) {
console.error("Error adding file to vector store:", error);
throw new Error("Failed to add file to vector store");
}
}
+85
View File
@@ -0,0 +1,85 @@
import { z } from "zod";
/**
* Schema for creating a new chat
*/
export const createChatSchema = z.object({
documentId: z.string().cuid().optional(),
dataroomId: z.string().cuid().optional(),
linkId: z.string().cuid().optional(),
viewId: z.string().cuid().optional(),
title: z.string().max(100).optional(),
viewerId: z.string().cuid().optional(),
});
/**
* Schema for sending a message
*/
export const sendMessageSchema = z.object({
content: z.string().min(1).max(10000),
/** Optional document ID to filter file_search results to a specific document */
filterDocumentId: z.string().cuid().optional(),
});
/**
* Schema for indexing a document
*/
export const indexDocumentSchema = z.object({
documentId: z.string().cuid(),
versionId: z.string().cuid().optional(),
});
/**
* Schema for indexing a dataroom
*/
export const indexDataroomSchema = z.object({
dataroomId: z.string().cuid(),
documentIds: z.array(z.string().cuid()).optional(),
});
/**
* Schema for chat query parameters
*/
export const chatQuerySchema = z.object({
teamId: z.string().cuid().optional(),
documentId: z.string().cuid().optional(),
dataroomId: z.string().cuid().optional(),
userId: z.string().cuid().optional(),
viewerId: z.string().cuid().optional(),
limit: z.number().int().min(1).max(100).optional().default(20),
offset: z.number().int().min(0).optional().default(0),
});
/**
* Schema for enabling agents on a document
*/
export const enableDocumentAgentsSchema = z.object({
agentsEnabled: z.boolean(),
});
/**
* Schema for enabling agents on a dataroom
*/
export const enableDataroomAgentsSchema = z.object({
agentsEnabled: z.boolean(),
});
/**
* Schema for enabling agents on a team
*/
export const enableTeamAgentsSchema = z.object({
agentsEnabled: z.boolean(),
});
export type CreateChatInput = z.infer<typeof createChatSchema>;
export type SendMessageInput = z.infer<typeof sendMessageSchema>;
export type IndexDocumentInput = z.infer<typeof indexDocumentSchema>;
export type IndexDataroomInput = z.infer<typeof indexDataroomSchema>;
export type ChatQueryInput = z.infer<typeof chatQuerySchema>;
export type EnableDocumentAgentsInput = z.infer<
typeof enableDocumentAgentsSchema
>;
export type EnableDataroomAgentsInput = z.infer<
typeof enableDataroomAgentsSchema
>;
export type EnableTeamAgentsInput = z.infer<typeof enableTeamAgentsSchema>;
+4 -2
View File
@@ -13,7 +13,8 @@ export type BetaFeatures =
| "slack"
| "annotations"
| "dataroomInvitations"
| "workflows";
| "workflows"
| "ai";
type BetaFeaturesRecord = Record<BetaFeatures, string[]>;
@@ -32,12 +33,13 @@ export const getFeatureFlags = async ({ teamId }: { teamId?: string }) => {
annotations: false,
dataroomInvitations: false,
workflows: false,
ai: false,
};
// Return all features as true if edge config is not available
if (!process.env.EDGE_CONFIG) {
return Object.fromEntries(
Object.entries(teamFeatures).map(([key, _v]) => [key, true]),
Object.entries(teamFeatures).map(([key, _v]) => [key, false]),
);
} else if (!teamId) {
return teamFeatures;
+82
View File
@@ -0,0 +1,82 @@
import { useTeam } from "@/context/team-context";
import { useSession } from "next-auth/react";
import useSWR from "swr";
import { BetaFeatures } from "@/lib/featureFlags";
import { TeamDetail, CustomUser } from "@/lib/types";
import { fetcher } from "@/lib/utils";
interface TeamAISettings {
agentsEnabled: boolean;
}
/**
* Hook to check team AI settings and user permissions
* Returns whether AI is feature-flagged for the team,
* whether it's enabled, and if the current user is an admin
*/
export function useTeamAI() {
const { data: session } = useSession();
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
// Fetch feature flags to check if AI is enabled for this team
const { data: features, isLoading: featuresLoading } = useSWR<
Record<BetaFeatures, boolean>
>(teamId ? `/api/feature-flags?teamId=${teamId}` : null, fetcher, {
dedupingInterval: 60000,
});
// Fetch team details to get agentsEnabled and user role
const { data: team, isLoading: teamLoading } = useSWR<TeamDetail>(
teamId ? `/api/teams/${teamId}` : null,
fetcher,
{
dedupingInterval: 20000,
},
);
// Fetch team AI settings
const {
data: aiSettings,
isLoading: aiSettingsLoading,
mutate: mutateAISettings,
} = useSWR<TeamAISettings>(
teamId ? `/api/teams/${teamId}/ai-settings` : null,
fetcher,
{
dedupingInterval: 10000,
},
);
const userId = (session?.user as CustomUser)?.id;
// Check if current user is admin
const isAdmin = team?.users.some(
(user) => user.role === "ADMIN" && user.userId === userId,
);
// Check if AI feature is available for this team (via feature flags)
const isAIFeatureEnabled = features?.ai ?? false;
// Check if AI is enabled for this team (team setting)
const isAIEnabled = aiSettings?.agentsEnabled ?? false;
return {
// Feature flag - is AI available for this team?
isAIFeatureEnabled,
// Team setting - is AI enabled for this team?
isAIEnabled,
// Is the current user an admin?
isAdmin,
// Can the user manage AI settings? (admin + feature enabled)
canManageAI: isAdmin && isAIFeatureEnabled,
// Is the feature ready to use? (feature enabled + team enabled)
canUseAI: isAIFeatureEnabled && isAIEnabled,
// Loading states
isLoading: featuresLoading || teamLoading || aiSettingsLoading,
// Mutate function to refresh AI settings
mutateAISettings,
};
}
+3636 -830
View File
File diff suppressed because it is too large Load Diff
+32 -22
View File
@@ -10,7 +10,7 @@
"build": "next build",
"start": "next start",
"lint": "next lint",
"postinstall": "prisma generate",
"postinstall": "patch-package && prisma generate",
"vercel-build": "prisma migrate deploy && next build",
"email": "email dev --dir ./components/emails --port 3001",
"trigger:v3:dev": "npx trigger.dev@3 dev",
@@ -20,11 +20,13 @@
"dev:prisma": "npx prisma generate && npx prisma migrate deploy"
},
"dependencies": {
"@aws-sdk/client-lambda": "^3.940.0",
"@aws-sdk/client-s3": "^3.940.0",
"@aws-sdk/cloudfront-signer": "^3.935.0",
"@aws-sdk/lib-storage": "^3.940.0",
"@aws-sdk/s3-request-presigner": "^3.940.0",
"@ai-sdk/openai": "^2.0.80",
"@ai-sdk/react": "^2.0.109",
"@aws-sdk/client-lambda": "^3.946.0",
"@aws-sdk/client-s3": "^3.946.0",
"@aws-sdk/cloudfront-signer": "^3.946.0",
"@aws-sdk/lib-storage": "^3.946.0",
"@aws-sdk/s3-request-presigner": "^3.946.0",
"@calcom/embed-react": "^1.5.3",
"@chronark/zod-bird": "^0.3.10",
"@dnd-kit/core": "^6.3.1",
@@ -34,7 +36,7 @@
"@hookform/resolvers": "^5.2.2",
"@jitsu/js": "^1.10.4",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/third-parties": "^15.5.6",
"@next/third-parties": "^16.0.7",
"@pdf-lib/fontkit": "^1.1.1",
"@prisma/client": "^6.5.0",
"@radix-ui/react-accordion": "^1.2.12",
@@ -44,6 +46,7 @@
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-portal": "^1.1.10",
@@ -58,6 +61,7 @@
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-use-controllable-state": "^1.2.2",
"@react-email/components": "^1.0.1",
"@react-email/render": "^2.0.0",
"@sindresorhus/slugify": "^3.0.0",
@@ -81,7 +85,8 @@
"@vercel/blob": "^2.0.0",
"@vercel/edge-config": "^1.4.3",
"@vercel/functions": "^3.3.4",
"ai": "2.2.37",
"@xyflow/react": "^12.9.3",
"ai": "^5.0.108",
"autoprefixer": "^10.4.22",
"base-x": "^5.0.1",
"bcryptjs": "^3.0.3",
@@ -91,42 +96,43 @@
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^3.6.0",
"dub": "^0.68.0",
"dub": "^0.69.0",
"embla-carousel-react": "^8.6.0",
"eslint": "8.57.0",
"eslint-config-next": "^14.2.33",
"exceljs": "^4.4.0",
"fluent-ffmpeg": "^2.1.3",
"input-otp": "^1.4.2",
"js-cookie": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lucide-react": "^0.555.0",
"jsonwebtoken": "^9.0.3",
"lucide-react": "^0.556.0",
"mime-types": "^3.0.2",
"motion": "^12.23.24",
"motion": "^12.23.25",
"ms": "^2.1.3",
"mupdf": "^1.26.4",
"nanoid": "^5.1.6",
"next": "^14.2.33",
"next-auth": "^4.24.13",
"next-plausible": "^3.12.4",
"next-themes": "^0.4.6",
"nodemailer": "^7.0.11",
"notion-client": "^7.7.1",
"notion-utils": "^7.7.1",
"nuqs": "^2.8.1",
"openai": "4.20.1",
"nuqs": "^2.8.3",
"openai": "^6.10.0",
"pdf-lib": "^1.17.1",
"postcss": "^8.5.6",
"posthog-js": "^1.298.1",
"posthog-js": "^1.302.2",
"react": "^18.3.1",
"react-colorful": "^5.6.1",
"react-day-picker": "^8.10.1",
"react-dom": "^18.3.1",
"react-draggable": "^4.5.0",
"react-dropzone": "^14.3.8",
"react-email": "^5.0.5",
"react-hook-form": "^7.66.1",
"react-email": "^5.0.6",
"react-hook-form": "^7.68.0",
"react-hotkeys-hook": "^5.2.1",
"react-intersection-observer": "^9.16.0",
"react-markdown": "^10.1.0",
"react-notion-x": "^7.7.1",
"react-pdf": "^8.0.2",
"react-phone-number-input": "^3.4.14",
@@ -135,25 +141,28 @@
"react-zoom-pan-pinch": "^3.7.0",
"resend": "^6.5.2",
"sanitize-html": "^2.17.0",
"shiki": "^3.17.0",
"shiki": "^3.19.0",
"sonner": "^2.0.7",
"streamdown": "^1.6.10",
"stripe": "^16.12.0",
"swr": "^2.3.7",
"tailwind-merge": "^2.6.0",
"tailwind-scrollbar-hide": "^2.0.0",
"tailwindcss": "^3.4.18",
"tailwindcss-animate": "^1.0.7",
"tokenlens": "^1.3.1",
"ts-pattern": "^5.9.0",
"tus-js-client": "^4.3.1",
"ua-parser-js": "^1.0.41",
"unsend": "^1.5.1",
"use-debounce": "^10.0.6",
"use-stick-to-bottom": "^1.1.1",
"vaul": "^1.1.2",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"zod": "^3.25.76"
},
"devDependencies": {
"@react-email/preview-server": "^5.0.5",
"@react-email/preview-server": "^5.0.6",
"@tailwindcss/forms": "^0.5.10",
"@trigger.dev/build": "^3.3.17",
"@trivago/prettier-plugin-sort-imports": "^6.0.0",
@@ -169,8 +178,9 @@
"@types/react-dom": "^18",
"@types/sanitize-html": "^2.13.0",
"@types/ua-parser-js": "^0.7.39",
"prettier": "^3.7.3",
"prettier-plugin-tailwindcss": "^0.7.1",
"patch-package": "^8.0.1",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"prisma": "^6.5.0",
"typescript": "^5"
},
+2 -1
View File
@@ -135,7 +135,7 @@ export default async function handle(
});
brand = teamBrand;
}
return res.status(200).json({ linkType, brand });
}
@@ -398,6 +398,7 @@ export default async function handle(
permissionGroupId: linkData.permissionGroupId || null,
audienceType: linkData.audienceType || LinkAudienceType.GENERAL,
enableConversation: linkData.enableConversation || false,
enableAIAgents: linkData.enableAIAgents || false,
enableUpload: linkData.enableUpload || false,
isFileRequestOnly: linkData.isFileRequestOnly || false,
uploadFolderId: linkData.uploadFolderId || null,
+1
View File
@@ -206,6 +206,7 @@ export default async function handler(
isFileRequestOnly: linkData.isFileRequestOnly,
uploadFolderId: linkData.uploadFolderId,
}),
enableAIAgents: linkData.enableAIAgents || false,
showBanner: linkData.showBanner,
...(linkData.customFields && {
customFields: {
+113
View File
@@ -0,0 +1,113 @@
import { NextApiRequest, NextApiResponse } from "next";
import { authOptions } from "@/pages/api/auth/[...nextauth]";
import { getServerSession } from "next-auth/next";
import { z } from "zod";
import { errorhandler } from "@/lib/errorHandler";
import { getFeatureFlags } from "@/lib/featureFlags";
import prisma from "@/lib/prisma";
import { CustomUser } from "@/lib/types";
const updateAISettingsSchema = z.object({
agentsEnabled: z.boolean(),
});
export default async function handle(
req: NextApiRequest,
res: NextApiResponse,
) {
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).end("Unauthorized");
}
const userId = (session.user as CustomUser).id;
const { teamId } = req.query as { teamId: string };
// Verify user has access to the team
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
select: { teamId: true, role: true },
});
if (!teamAccess) {
return res.status(401).end("Unauthorized");
}
// Check if AI feature is enabled for this team
const features = await getFeatureFlags({ teamId });
if (!features.ai) {
return res
.status(403)
.json({ error: "AI feature is not available for this team" });
}
if (req.method === "GET") {
// GET /api/teams/:teamId/ai-settings
try {
const team = await prisma.team.findUnique({
where: { id: teamId },
select: {
agentsEnabled: true,
vectorStoreId: true,
},
});
if (!team) {
return res.status(404).json({ error: "Team not found" });
}
return res.status(200).json({
agentsEnabled: team.agentsEnabled,
vectorStoreId: team.vectorStoreId,
});
} catch (error) {
errorhandler(error, res);
}
} else if (req.method === "PATCH") {
// PATCH /api/teams/:teamId/ai-settings
// Only admins can update AI settings
if (teamAccess.role !== "ADMIN") {
return res.status(403).json({
error: "Only team admins can manage AI settings",
});
}
try {
const validation = updateAISettingsSchema.safeParse(req.body);
if (!validation.success) {
return res.status(400).json({
error: "Invalid request body",
details: validation.error,
});
}
const { agentsEnabled } = validation.data;
const updatedTeam = await prisma.team.update({
where: { id: teamId },
data: { agentsEnabled },
select: {
agentsEnabled: true,
vectorStoreId: true,
},
});
return res.status(200).json({
agentsEnabled: updatedTeam.agentsEnabled,
vectorStoreId: updatedTeam.vectorStoreId,
});
} catch (error) {
errorhandler(error, res);
}
} else {
res.setHeader("Allow", ["GET", "PATCH"]);
return res.status(405).end(`Method ${req.method} Not Allowed`);
}
}
@@ -28,19 +28,15 @@ export default async function handle(
const userId = (session.user as CustomUser).id;
try {
// Check if the user is part of the team
const team = await prisma.team.findUnique({
const teamAccess = await prisma.userTeam.findUnique({
where: {
id: teamId,
users: {
some: {
userId: userId,
},
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
});
if (!team) {
if (!teamAccess) {
return res.status(401).end("Unauthorized");
}
@@ -119,6 +115,7 @@ export default async function handle(
allowBulkDownload,
showLastUpdated,
tags,
agentsEnabled,
} = req.body as {
name?: string;
enableChangeNotifications?: boolean;
@@ -126,6 +123,7 @@ export default async function handle(
allowBulkDownload?: boolean;
showLastUpdated?: boolean;
tags?: string[];
agentsEnabled?: boolean;
};
const featureFlags = await getFeatureFlags({ teamId: team.id });
@@ -143,6 +141,12 @@ export default async function handle(
});
}
if (agentsEnabled !== undefined && !featureFlags.ai) {
return res.status(403).json({
message: "This feature is not available in your plan",
});
}
const updatedDataroom = await prisma.$transaction(async (tx) => {
const dataroom = await tx.dataroom.update({
where: {
@@ -160,6 +164,9 @@ export default async function handle(
...(typeof showLastUpdated === "boolean" && {
showLastUpdated,
}),
...(typeof agentsEnabled === "boolean" && {
agentsEnabled,
}),
},
});
@@ -182,6 +182,67 @@ export default async function handle(
newPath: document.folder?.path,
oldPath: currentPathName,
});
} else if (req.method === "PATCH") {
// PATCH /api/teams/:teamId/documents/:id
// Update document settings (e.g., agentsEnabled)
const session = await getServerSession(req, res, authOptions);
if (!session) {
return res.status(401).json({ message: "Unauthorized" });
}
const { teamId, id: docId } = req.query as { teamId: string; id: string };
const userId = (session.user as CustomUser).id;
try {
// Verify user has access to the team
const teamAccess = await prisma.userTeam.findUnique({
where: {
userId_teamId: {
userId: userId,
teamId: teamId,
},
},
select: { role: true },
});
if (!teamAccess) {
return res.status(401).json({ message: "Unauthorized" });
}
// Extract allowed fields from request body
const { agentsEnabled } = req.body as {
agentsEnabled?: boolean;
};
// Build update data object with only provided fields
const updateData: { agentsEnabled?: boolean } = {};
if (typeof agentsEnabled === "boolean") {
updateData.agentsEnabled = agentsEnabled;
}
// Check if there's anything to update
if (Object.keys(updateData).length === 0) {
return res.status(400).json({ message: "No valid fields to update" });
}
// Update the document
const document = await prisma.document.update({
where: {
id: docId,
teamId: teamId,
},
data: updateData,
select: {
id: true,
agentsEnabled: true,
},
});
return res.status(200).json(document);
} catch (error) {
errorhandler(error, res);
}
} else if (req.method === "DELETE") {
// DELETE /api/teams/:teamId/document/:id
const session = await getServerSession(req, res, authOptions);
@@ -261,8 +322,8 @@ export default async function handle(
errorhandler(error, res);
}
} else {
// We only allow GET, PUT and DELETE requests
res.setHeader("Allow", ["GET", "PUT", "DELETE"]);
// We only allow GET, PUT, PATCH and DELETE requests
res.setHeader("Allow", ["GET", "PUT", "PATCH", "DELETE"]);
return res
.status(405)
.json({ message: `Method ${req.method} Not Allowed` });
+316
View File
@@ -0,0 +1,316 @@
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { useTeam } from "@/context/team-context";
import { Bot, ExternalLink, Shield, Sparkles } from "lucide-react";
import { useSession } from "next-auth/react";
import { toast } from "sonner";
import useSWR from "swr";
import { useFeatureFlags } from "@/lib/hooks/use-feature-flags";
import { useGetTeam } from "@/lib/swr/use-team";
import { CustomUser } from "@/lib/types";
import { fetcher } from "@/lib/utils";
import AppLayout from "@/components/layouts/app";
import { SettingsHeader } from "@/components/settings/settings-header";
import { Badge } from "@/components/ui/badge";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import LoadingSpinner from "@/components/ui/loading-spinner";
import { Switch } from "@/components/ui/switch";
interface AISettings {
agentsEnabled: boolean;
vectorStoreId: string | null;
}
export default function AISettings() {
const router = useRouter();
const { data: session } = useSession();
const teamInfo = useTeam();
const teamId = teamInfo?.currentTeam?.id;
const { team, loading: teamLoading } = useGetTeam();
const [updating, setUpdating] = useState(false);
// Check if AI feature is enabled for this team
const { isFeatureEnabled, isLoading: featuresLoading } = useFeatureFlags();
const isAIFeatureEnabled = isFeatureEnabled("ai");
// Fetch AI settings
const {
data: aiSettings,
isLoading: aiSettingsLoading,
mutate: mutateAISettings,
} = useSWR<AISettings>(
teamId && isAIFeatureEnabled ? `/api/teams/${teamId}/ai-settings` : null,
fetcher,
);
const userId = (session?.user as CustomUser)?.id;
// Check if current user is admin
const isAdmin = team?.users.some(
(user) => user.role === "ADMIN" && user.userId === userId,
);
// Redirect if feature is not enabled
useEffect(() => {
if (!featuresLoading && !isAIFeatureEnabled) {
router.push("/settings/general");
toast.error("AI features are not available for your team");
}
}, [featuresLoading, isAIFeatureEnabled, router]);
const handleToggleAI = async (enabled: boolean) => {
if (!teamId || !isAdmin) return;
setUpdating(true);
try {
const response = await fetch(`/api/teams/${teamId}/ai-settings`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ agentsEnabled: enabled }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update AI settings");
}
await mutateAISettings();
toast.success(
`AI Agents ${enabled ? "enabled" : "disabled"} for your team`,
);
} catch (error) {
console.error("Error updating AI settings:", error);
toast.error((error as Error).message || "Failed to update AI settings");
} finally {
setUpdating(false);
}
};
if (featuresLoading || teamLoading) {
return (
<AppLayout>
<main className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
<SettingsHeader />
<div className="flex h-64 items-center justify-center">
<LoadingSpinner className="h-8 w-8" />
</div>
</main>
</AppLayout>
);
}
if (!isAIFeatureEnabled) {
return null;
}
return (
<AppLayout>
<main className="relative mx-2 mb-10 mt-4 space-y-8 overflow-hidden px-1 sm:mx-3 md:mx-5 md:mt-5 lg:mx-7 lg:mt-8 xl:mx-10">
<SettingsHeader />
<div className="mb-4 flex items-center justify-between md:mb-8 lg:mb-12">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-2xl font-semibold tracking-tight text-foreground">
AI Agents
</h3>
<Badge variant="secondary" className="gap-1">
<Sparkles className="h-3 w-3" />
Beta
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Configure AI-powered chat for your documents and datarooms
</p>
</div>
</div>
<div className="space-y-6">
{/* Main AI Toggle Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-primary" />
<CardTitle>Enable AI Agents</CardTitle>
</div>
<CardDescription>
Allow AI-powered chat on documents and datarooms in your team.
When enabled, you can activate AI chat on individual documents.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="ai-enabled" className="flex flex-col space-y-1">
<span>AI Agents for Team</span>
<span className="text-xs font-normal leading-snug text-muted-foreground">
{isAdmin
? "Enable to allow AI chat on documents in your team"
: "Only team admins can change this setting"}
</span>
</Label>
<Switch
id="ai-enabled"
checked={aiSettings?.agentsEnabled ?? false}
onCheckedChange={handleToggleAI}
disabled={updating || aiSettingsLoading || !isAdmin}
/>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3">
<p className="text-sm text-muted-foreground transition-colors">
Once enabled, you can turn on AI chat for individual documents
from their settings page.
</p>
</CardFooter>
</Card>
{/* Privacy & Data Card */}
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-green-600" />
<CardTitle>Privacy & Data Usage</CardTitle>
</div>
<CardDescription>
How we handle your data with AI features
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-3 text-sm">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30">
</div>
<div>
<p className="font-medium">Powered by OpenAI</p>
<p className="text-muted-foreground">
We use OpenAI&apos;s API to power AI chat features. Your
documents are processed to enable intelligent Q&A.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30">
</div>
<div>
<p className="font-medium">No Training on Your Data</p>
<p className="text-muted-foreground">
OpenAI does not use data sent through their API to train
their models. Your content remains private.
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-600 dark:bg-green-900/30">
</div>
<div>
<p className="font-medium">Data Retention</p>
<p className="text-muted-foreground">
Document embeddings are stored securely and can be deleted
at any time by disabling AI for a document.
</p>
</div>
</div>
</div>
</CardContent>
<CardFooter className="flex items-center justify-between rounded-b-lg border-t bg-muted px-6 py-3">
<a
href="https://openai.com/policies/api-data-usage-policies"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-sm text-muted-foreground transition-colors hover:underline"
>
Learn more about OpenAI&apos;s data usage policies
<ExternalLink className="h-3 w-3" />
</a>
</CardFooter>
</Card>
{/* How it Works Card */}
<Card>
<CardHeader>
<CardTitle>How AI Agents Work</CardTitle>
<CardDescription>
A quick overview of the AI chat feature
</CardDescription>
</CardHeader>
<CardContent>
<ol className="space-y-3 text-sm">
<li className="flex items-start gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground">
1
</span>
<div>
<p className="font-medium">Enable AI for your team</p>
<p className="text-muted-foreground">
Turn on the toggle above (admin only)
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground">
2
</span>
<div>
<p className="font-medium">Activate AI on documents</p>
<p className="text-muted-foreground">
Go to a document&apos;s settings and enable AI Agents
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground">
3
</span>
<div>
<p className="font-medium">Index your documents</p>
<p className="text-muted-foreground">
Click &quot;Index Document&quot; to prepare it for AI chat
</p>
</div>
</li>
<li className="flex items-start gap-3">
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-medium text-primary-foreground">
4
</span>
<div>
<p className="font-medium">Visitors can chat</p>
<p className="text-muted-foreground">
Viewers can ask questions and get AI-powered answers about
your document
</p>
</div>
</li>
</ol>
</CardContent>
</Card>
</div>
</main>
</AppLayout>
);
}
@@ -0,0 +1,105 @@
-- DropIndex
DROP INDEX "Chat_threadId_documentId_key";
-- DropIndex
DROP INDEX "Chat_threadId_idx";
-- DropIndex
DROP INDEX "Chat_threadId_key";
-- DropIndex
DROP INDEX "Chat_userId_documentId_key";
-- AlterTable
ALTER TABLE "Chat" DROP COLUMN "threadId",
ADD COLUMN "dataroomId" TEXT,
ADD COLUMN "linkId" TEXT,
ADD COLUMN "teamId" TEXT NOT NULL,
ADD COLUMN "title" TEXT,
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL,
ADD COLUMN "vectorStoreId" TEXT,
ADD COLUMN "viewId" TEXT,
ADD COLUMN "viewerId" TEXT,
ALTER COLUMN "userId" DROP NOT NULL,
ALTER COLUMN "documentId" DROP NOT NULL;
-- AlterTable
ALTER TABLE "Dataroom" ADD COLUMN "agentsEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "vectorStoreId" TEXT;
-- AlterTable
ALTER TABLE "DataroomDocument" ADD COLUMN "vectorStoreFileId" TEXT;
-- AlterTable
ALTER TABLE "Document" ADD COLUMN "agentsEnabled" BOOLEAN NOT NULL DEFAULT false;
-- AlterTable
ALTER TABLE "DocumentVersion" ADD COLUMN "vectorStoreFileId" TEXT;
-- AlterTable
ALTER TABLE "Link" ADD COLUMN "enableAIAgents" BOOLEAN DEFAULT false;
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "agentsEnabled" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "vectorStoreId" TEXT;
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"chatId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT NOT NULL,
"metadata" JSONB,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "ChatMessage_chatId_createdAt_idx" ON "ChatMessage"("chatId" ASC, "createdAt" ASC);
-- CreateIndex
CREATE INDEX "ChatMessage_chatId_idx" ON "ChatMessage"("chatId" ASC);
-- CreateIndex
CREATE INDEX "Chat_createdAt_idx" ON "Chat"("createdAt" DESC);
-- CreateIndex
CREATE INDEX "Chat_dataroomId_idx" ON "Chat"("dataroomId" ASC);
-- CreateIndex
CREATE INDEX "Chat_documentId_idx" ON "Chat"("documentId" ASC);
-- CreateIndex
CREATE INDEX "Chat_linkId_idx" ON "Chat"("linkId" ASC);
-- CreateIndex
CREATE INDEX "Chat_teamId_idx" ON "Chat"("teamId" ASC);
-- CreateIndex
CREATE INDEX "Chat_userId_idx" ON "Chat"("userId" ASC);
-- CreateIndex
CREATE INDEX "Chat_viewId_idx" ON "Chat"("viewId" ASC);
-- CreateIndex
CREATE INDEX "Chat_viewerId_idx" ON "Chat"("viewerId" ASC);
-- AddForeignKey
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_dataroomId_fkey" FOREIGN KEY ("dataroomId") REFERENCES "Dataroom"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_linkId_fkey" FOREIGN KEY ("linkId") REFERENCES "Link"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_viewId_fkey" FOREIGN KEY ("viewId") REFERENCES "View"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_viewerId_fkey" FOREIGN KEY ("viewerId") REFERENCES "Viewer"("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;
+7
View File
@@ -22,6 +22,11 @@ model Dataroom {
// FAQ system
faqItems DataroomFaqItem[]
// AI agents
agentsEnabled Boolean @default(false) // Enable AI agents for this dataroom
vectorStoreId String? // OpenAI vector store ID for AI search
chats Chat[]
// upload external documents
uploadedDocuments DocumentUpload[]
@@ -64,6 +69,8 @@ model DataroomDocument {
uploadedDocuments DocumentUpload[]
vectorStoreFileId String? // vector store file ID for AI search
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+7 -5
View File
@@ -14,6 +14,7 @@ model Document {
ownerId String? // This field holds the foreign key.
assistantEnabled Boolean @default(false) // This indicates if assistant is enabled for this document
advancedExcelEnabled Boolean @default(false) // This indicates if advanced Excel is enabled for this document
agentsEnabled Boolean @default(false) // This indicates if AI agents are enabled for this document
downloadOnly Boolean @default(false) // Indicates if the document is download only
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -52,11 +53,12 @@ model DocumentVersion {
fileSize BigInt? // This should be the size of the file in bytes
storageType DocumentStorageType @default(VERCEL_BLOB)
numPages Int? // This should be a reference to the number of pages in the document
isPrimary Boolean @default(false) // Indicates if this is the primary version
isVertical Boolean @default(false) // Indicates if the document is vertical (portrait) or not (landscape)
fileId String? // This is the file ID of the OpenAI File API
pages DocumentPage[]
hasPages Boolean @default(false) // Indicates if the document has pages
isPrimary Boolean @default(false) // Indicates if this is the primary version
isVertical Boolean @default(false) // Indicates if the document is vertical (portrait) or not (landscape)
fileId String? // This is the file ID of the OpenAI File API
vectorStoreFileId String? // OpenAI vector store file ID for AI search
pages DocumentPage[]
hasPages Boolean @default(false) // Indicates if the document has pages
length Int? // This is the length of the video in seconds
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
+4
View File
@@ -73,6 +73,10 @@ model Link {
// FAQ system
dataroomFaqItems DataroomFaqItem[]
// AI chats
enableAIAgents Boolean? @default(false) // Enable AI agents for this link
chats Chat[]
// upload
enableUpload Boolean? @default(false) // Optional give user a option to enable the upload document function
isFileRequestOnly Boolean? @default(false) // Optional give user a option to enable the file request only
+65 -11
View File
@@ -145,6 +145,9 @@ model View {
uploadedDocuments DocumentUpload[] // uploaded documents by this view
// AI chats
chats Chat[]
teamId String?
team Team? @relation(fields: [teamId], references: [id], onDelete: Cascade)
@@ -193,6 +196,9 @@ model Viewer {
uploadedDocuments DocumentUpload[] // uploaded documents by this viewer
// AI chats
chats Chat[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -246,18 +252,66 @@ model SentEmail {
}
model Chat {
id String @id @default(cuid())
threadId String @unique // This is the thread ID from OpenAI Assistant API
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
documentId String
createdAt DateTime @default(now())
lastMessageAt DateTime? // This is the last time a message was sent in the thread
id String @id @default(cuid())
title String? // Generated title from first message
@@unique([userId, documentId])
@@unique([threadId, documentId])
@@index([threadId])
// Context associations
teamId String
team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
documentId String?
document Document? @relation(fields: [documentId], references: [id], onDelete: Cascade)
dataroomId String?
dataroom Dataroom? @relation(fields: [dataroomId], references: [id], onDelete: Cascade)
linkId String?
link Link? @relation(fields: [linkId], references: [id], onDelete: Cascade)
viewId String?
view View? @relation(fields: [viewId], references: [id], onDelete: Cascade)
// User associations (internal or external)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
viewerId String?
viewer Viewer? @relation(fields: [viewerId], references: [id], onDelete: Cascade)
// OpenAI references
vectorStoreId String? // The vector store used for this chat
messages ChatMessage[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
lastMessageAt DateTime? // Track latest activity
@@index([teamId])
@@index([documentId])
@@index([dataroomId])
@@index([linkId])
@@index([userId])
@@index([viewerId])
@@index([viewId])
@@index([createdAt(sort: Desc)])
}
model ChatMessage {
id String @id @default(cuid())
chatId String
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
role String // "user" | "assistant" | "system"
content String @db.Text
// Optional structured data
metadata Json? // Store sources, page numbers, confidence scores, etc.
createdAt DateTime @default(now())
@@index([chatId])
@@index([chatId, createdAt])
}
model Feedback {
+5
View File
@@ -46,6 +46,11 @@ model Team {
enableExcelAdvancedMode Boolean @default(false) // Enable Excel advanced mode for all documents in the team
replicateDataroomFolders Boolean @default(true) // Replicate dataroom folder structure in "All Documents"
// AI agents
agentsEnabled Boolean @default(false) // Enable AI agents for the team
vectorStoreId String? // OpenAI vector store ID for team-level document search
chats Chat[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt