feat(ee): add agent chat
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
../ee/LICENSE.md
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }),
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "{searchQuery}"
|
||||
</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 "{searchQuery}"
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Generated
+3636
-830
File diff suppressed because it is too large
Load Diff
+32
-22
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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` });
|
||||
|
||||
@@ -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'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'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'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 "Index Document" 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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user