mirror of
https://github.com/mfts/papermark.git
synced 2025-12-20 01:03:24 +08:00
chore: remove deprecated chat
This commit is contained in:
@@ -1 +0,0 @@
|
||||
oss-gg
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import { AssistantStatus, type Message } from "ai/react";
|
||||
import Textarea from "react-textarea-autosize";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useEnterSubmit } from "@/lib/utils/use-enter-submit";
|
||||
|
||||
import ArrowUp from "../shared/icons/arrow-up";
|
||||
|
||||
export function ChatInput({
|
||||
status,
|
||||
error,
|
||||
input,
|
||||
setInput,
|
||||
handleInputChange,
|
||||
submitMessage,
|
||||
messages,
|
||||
}: {
|
||||
status: AssistantStatus;
|
||||
error: unknown;
|
||||
input: string;
|
||||
setInput: (input: string) => void;
|
||||
handleInputChange: (e: any) => void;
|
||||
submitMessage: (e: any) => Promise<void>;
|
||||
messages: Message[];
|
||||
}) {
|
||||
const { formRef, onKeyDown } = useEnterSubmit();
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
useEffect(() => {
|
||||
if (status === "awaiting_message") {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div className="relative inset-x-0 bottom-0">
|
||||
<div className="mx-auto sm:max-w-3xl sm:px-4">
|
||||
<div className="space-y-4 bg-background px-4 py-4 md:py-4">
|
||||
<form onSubmit={submitMessage} ref={formRef}>
|
||||
<div className="relative flex max-h-60 w-full flex-col overflow-hidden rounded-xl bg-background pr-8 ring-1 ring-muted-foreground/50 focus-within:ring-1 focus-within:ring-foreground sm:pr-12">
|
||||
<Textarea
|
||||
ref={inputRef}
|
||||
tabIndex={0}
|
||||
rows={1}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={status !== "awaiting_message"}
|
||||
className="min-h-[60px] w-full resize-none border-none bg-transparent px-4 py-[1.3rem] focus:ring-0 sm:text-sm"
|
||||
value={input}
|
||||
placeholder="Message Papermark Assistant..."
|
||||
onChange={handleInputChange}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className="absolute bottom-3 right-3">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={status === "in_progress" || input === ""}
|
||||
title="Send message"
|
||||
className="h-10 w-10 rounded-md p-1 md:p-2"
|
||||
>
|
||||
<ArrowUp className="h-full w-full" />
|
||||
<span className="sr-only">Send message</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconArrowElbow({ className, ...props }: React.ComponentProps<"svg">) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"
|
||||
fill="currentColor"
|
||||
className={cn("h-4 w-4", className)}
|
||||
{...props}
|
||||
>
|
||||
<path d="M200 32v144a8 8 0 0 1-8 8H67.31l34.35 34.34a8 8 0 0 1-11.32 11.32l-48-48a8 8 0 0 1 0-11.32l48-48a8 8 0 0 1 11.32 11.32L67.31 168H184V32a8 8 0 0 1 16 0Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { type Message } from "ai";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import Skeleton from "../Skeleton";
|
||||
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
|
||||
import { ChatMessage } from "./chat-message";
|
||||
|
||||
export interface ChatList {
|
||||
messages: Message[];
|
||||
status: "in_progress" | "awaiting_message";
|
||||
}
|
||||
|
||||
export function ChatList({ messages, status }: ChatList) {
|
||||
if (!messages.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative mx-auto max-w-2xl px-4">
|
||||
{messages.map((message, index) => (
|
||||
<div key={index}>
|
||||
<ChatMessage message={message} />
|
||||
{index < messages.length - 1 && (
|
||||
<Separator className="my-4 bg-background" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{status === "in_progress" && (
|
||||
<>
|
||||
<Separator className="my-4 bg-background" />
|
||||
<div
|
||||
key={"loading-message"}
|
||||
className="group relative mb-4 ml-5 flex items-start whitespace-pre-wrap"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-primary text-primary-foreground shadow">
|
||||
<PapermarkSparkle />
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<div className="select-none font-semibold">
|
||||
Papermark Assistant
|
||||
</div>
|
||||
<Skeleton className="h-4 w-64" />
|
||||
<Skeleton className="h-4 w-48" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { type Message } from "ai";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCopyToClipboard } from "@/lib/utils/use-copy-to-clipboard";
|
||||
|
||||
import Check from "../shared/icons/check";
|
||||
import Copy from "../shared/icons/copy";
|
||||
|
||||
interface ChatMessageActionsProps extends React.ComponentProps<"div"> {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatMessageActions({
|
||||
message,
|
||||
className,
|
||||
...props
|
||||
}: ChatMessageActionsProps) {
|
||||
const { isCopied, copyToClipboard } = useCopyToClipboard({ timeout: 2000 });
|
||||
|
||||
const onCopy = () => {
|
||||
if (isCopied) return;
|
||||
copyToClipboard(message.content);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-end md:absolute md:-top-2 md:right-0 md:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Button variant="ghost" size="icon" onClick={onCopy}>
|
||||
{isCopied ? <Check /> : <Copy />}
|
||||
<span className="sr-only">Copy message</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { type Message } from "ai";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import AlertCircle from "../shared/icons/alert-circle";
|
||||
import PapermarkSparkle from "../shared/icons/papermark-sparkle";
|
||||
import UserRound from "../shared/icons/user-round";
|
||||
import { ChatMessageActions } from "./chat-message-actions";
|
||||
|
||||
// map role to icon and name
|
||||
const mapMessageRole = {
|
||||
user: { icon: <UserRound />, name: "You" },
|
||||
system: { icon: <AlertCircle />, name: "System" },
|
||||
assistant: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
function: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
data: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
tool: { icon: <PapermarkSparkle />, name: "Papermark Assistant" },
|
||||
};
|
||||
|
||||
export interface ChatMessageProps {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function ChatMessage({ message, ...props }: ChatMessageProps) {
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"group relative mb-4 flex items-start whitespace-pre-wrap md:ml-5",
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow",
|
||||
message.role === "user"
|
||||
? "bg-background"
|
||||
: message.role === "system"
|
||||
? "bg-destructive text-destructive-foreground"
|
||||
: "bg-primary text-primary-foreground",
|
||||
)}
|
||||
>
|
||||
{mapMessageRole[message.role].icon}
|
||||
</div>
|
||||
<div className="ml-4 flex-1 space-y-2 overflow-hidden px-1">
|
||||
<div className="group relative flex w-[calc(100%-50px)] flex-col">
|
||||
<div className="select-none font-semibold">
|
||||
{mapMessageRole[message.role].name}
|
||||
</div>
|
||||
<div className="prose break-words font-light dark:prose-invert prose-p:leading-relaxed prose-pre:p-0">
|
||||
<p className="mb-2 last:mb-0">{message.content}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ChatMessageActions className="group-hover:flex" message={message} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
|
||||
import { useInView } from "react-intersection-observer";
|
||||
|
||||
import { useAtBottom } from "@/lib/utils/use-at-bottom";
|
||||
|
||||
interface ChatScrollAnchorProps {
|
||||
trackVisibility?: boolean;
|
||||
}
|
||||
|
||||
export function ChatScrollAnchor({ trackVisibility }: ChatScrollAnchorProps) {
|
||||
const isAtBottom = useAtBottom();
|
||||
const { ref, entry, inView } = useInView({
|
||||
trackVisibility,
|
||||
delay: 100,
|
||||
rootMargin: "0px 0px -100px 0px",
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isAtBottom && trackVisibility && !inView) {
|
||||
entry?.target.scrollIntoView({
|
||||
block: "start",
|
||||
});
|
||||
}
|
||||
}, [inView, entry, isAtBottom, trackVisibility]);
|
||||
|
||||
return <div ref={ref} className="h-px w-full" />;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
type Message,
|
||||
experimental_useAssistant as useAssistant,
|
||||
} from "ai/react";
|
||||
import { nanoid } from "nanoid";
|
||||
|
||||
import { BasePlan } from "@/lib/swr/use-billing";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { ChatList } from "./chat-list";
|
||||
import { ChatScrollAnchor } from "./chat-scroll-anchor";
|
||||
import { EmptyScreen } from "./empty-screen";
|
||||
|
||||
export interface ChatProps extends React.ComponentProps<"div"> {
|
||||
initialMessages: Message[];
|
||||
threadId?: string;
|
||||
firstPage?: string;
|
||||
isPublic?: boolean;
|
||||
userId?: string;
|
||||
plan?: BasePlan | null;
|
||||
}
|
||||
|
||||
export function Chat({
|
||||
initialMessages,
|
||||
threadId,
|
||||
firstPage,
|
||||
className,
|
||||
isPublic,
|
||||
userId,
|
||||
plan,
|
||||
}: ChatProps) {
|
||||
const {
|
||||
status,
|
||||
messages: hookMessages,
|
||||
input: hookInput,
|
||||
submitMessage,
|
||||
handleInputChange,
|
||||
error,
|
||||
} = useAssistant({
|
||||
api: "/api/assistants/chat",
|
||||
threadId: threadId,
|
||||
body: {
|
||||
isPublic: isPublic,
|
||||
userId: userId,
|
||||
plan: plan,
|
||||
},
|
||||
});
|
||||
|
||||
const [combinedMessages, setCombinedMessages] = useState<Message[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error instanceof Error) {
|
||||
let content: string = "";
|
||||
if (isPublic) {
|
||||
content =
|
||||
"You have reached your request limit for the day. Sign up for a free account to continue using Papermark Assistant.";
|
||||
}
|
||||
if (userId && plan !== "pro") {
|
||||
content =
|
||||
"You have reached your request limit for the day. Upgrade to a paid account to continue using Papermark Assistant.";
|
||||
}
|
||||
|
||||
const message: Message = {
|
||||
role: "system",
|
||||
content: content,
|
||||
id: nanoid(),
|
||||
};
|
||||
|
||||
setCombinedMessages((prev) => [...prev, message]);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
useEffect(() => {
|
||||
// Concatenate existing messages with messages from the hook
|
||||
// and reverse the order so that the newest messages are at the bottom:
|
||||
const reversedMessages = [...initialMessages].reverse();
|
||||
setCombinedMessages([...reversedMessages, ...hookMessages]);
|
||||
}, [initialMessages, hookMessages]);
|
||||
|
||||
let isLoading;
|
||||
if (status === "in_progress") {
|
||||
isLoading = true;
|
||||
} else if (status === "awaiting_message") {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
const [_, setInput] = useState<string>(hookInput);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative h-[calc(100vh-96px)] overflow-y-auto pb-[20px] pt-24",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{combinedMessages.length ? (
|
||||
<>
|
||||
<ChatList messages={combinedMessages} status={status} />
|
||||
{!isPublic ? (
|
||||
<ChatScrollAnchor trackVisibility={isLoading} />
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<EmptyScreen
|
||||
firstPage={firstPage}
|
||||
handleInputChange={handleInputChange}
|
||||
setInput={setInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ChatInput
|
||||
status={status}
|
||||
error={error}
|
||||
messages={combinedMessages}
|
||||
input={hookInput}
|
||||
setInput={setInput}
|
||||
submitMessage={submitMessage}
|
||||
handleInputChange={handleInputChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import Image from "next/image";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import Sparkle from "../shared/icons/sparkle";
|
||||
|
||||
const exampleMessages = [
|
||||
{
|
||||
heading: "Summarize the document",
|
||||
message: "Summarize the document in a few sentences and bullet points.",
|
||||
},
|
||||
{
|
||||
heading: "Tell me about the team",
|
||||
message:
|
||||
"Tell me more about the team behind the company. If there's no team page in the document, then let me know.",
|
||||
},
|
||||
{
|
||||
heading: "Ask a question",
|
||||
message: "What does the company do?",
|
||||
},
|
||||
{
|
||||
heading: "What is this document about?",
|
||||
message: "Please tell me in one sentence what this document is about.",
|
||||
},
|
||||
];
|
||||
|
||||
export function EmptyScreen({
|
||||
firstPage,
|
||||
setInput,
|
||||
handleInputChange,
|
||||
}: {
|
||||
firstPage?: string;
|
||||
setInput: (input: string) => void;
|
||||
handleInputChange: (e: any) => void;
|
||||
}) {
|
||||
const manageInput = (message: string) => {
|
||||
setInput(message);
|
||||
handleInputChange(message);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mx-auto w-1/2 md:w-1/3">
|
||||
<div className="flex items-center justify-center">
|
||||
{firstPage ? (
|
||||
firstPage.includes("cloudfront.net") ? (
|
||||
<img
|
||||
className="rounded-md object-contain ring-1 ring-gray-700"
|
||||
src={firstPage}
|
||||
alt={`Page 1`}
|
||||
fetchPriority="high"
|
||||
/>
|
||||
) : (
|
||||
<Image
|
||||
src={firstPage}
|
||||
width={768}
|
||||
height={100}
|
||||
className="rounded-md object-contain ring-1 ring-gray-700"
|
||||
alt="First page of the document"
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<div className="flex w-full items-center justify-center rounded-md">
|
||||
<p className="text-center text-2xl">
|
||||
Chat with{" "}
|
||||
<span className="text-2xl font-bold tracking-tighter ">
|
||||
Papermark
|
||||
</span>
|
||||
's pitchdeck
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-center text-lg">
|
||||
What would you like to know?
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-x-0 bottom-0 mx-auto max-w-2xl px-4">
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{exampleMessages.map((message, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
variant="outline"
|
||||
className="h-auto p-4 text-sm"
|
||||
onClick={() => {
|
||||
return handleInputChange({
|
||||
target: { value: message.message },
|
||||
});
|
||||
}}
|
||||
>
|
||||
{message.heading}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { experimental_AssistantResponse } from "ai";
|
||||
import { type MessageContentText } from "openai/resources/beta/threads/messages/messages";
|
||||
import { type Run } from "openai/resources/beta/threads/runs/runs";
|
||||
|
||||
import { openai } from "@/lib/openai";
|
||||
import { redis } from "@/lib/redis";
|
||||
|
||||
const ratelimit = {
|
||||
public: new Ratelimit({
|
||||
redis,
|
||||
analytics: true,
|
||||
prefix: "ratelimit:public",
|
||||
// rate limit public to 3 request per hour
|
||||
limiter: Ratelimit.slidingWindow(3, "1h"),
|
||||
}),
|
||||
free: new Ratelimit({
|
||||
redis,
|
||||
analytics: true,
|
||||
prefix: "ratelimit:free",
|
||||
// rate limit to 3 request per day
|
||||
limiter: Ratelimit.fixedWindow(3, "24h"),
|
||||
}),
|
||||
paid: new Ratelimit({
|
||||
redis,
|
||||
analytics: true,
|
||||
prefix: "ratelimit:paid",
|
||||
limiter: Ratelimit.slidingWindow(60, "10s"),
|
||||
}),
|
||||
pro: new Ratelimit({
|
||||
redis,
|
||||
analytics: true,
|
||||
prefix: "ratelimit:pro",
|
||||
// rate limit to 1000 request per 30 days
|
||||
limiter: Ratelimit.fixedWindow(1000, "30d"),
|
||||
}),
|
||||
};
|
||||
|
||||
// IMPORTANT! Set the runtime to edge
|
||||
export const config = {
|
||||
runtime: "edge",
|
||||
};
|
||||
|
||||
export default async function POST(req: Request) {
|
||||
// Parse the request body
|
||||
const input: {
|
||||
threadId: string | null;
|
||||
message: string;
|
||||
isPublic: boolean | null;
|
||||
userId: string | null;
|
||||
plan: string | null;
|
||||
} = await req.json();
|
||||
|
||||
if (input.isPublic) {
|
||||
const ip = req.headers.get("x-forwarded-for");
|
||||
|
||||
const { success, limit, reset, remaining } = await ratelimit.public.limit(
|
||||
`ratelimit_${ip}`,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return new Response("You have reached your request limit for the day.", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": limit.toString(),
|
||||
"X-RateLimit-Remaining": remaining.toString(),
|
||||
"X-RateLimit-Reset": reset.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (input.userId && input.plan !== "pro") {
|
||||
const { success, limit, reset, remaining } = await ratelimit.free.limit(
|
||||
`ratelimit_${input.userId}`,
|
||||
);
|
||||
|
||||
if (!success) {
|
||||
return new Response("You have reached your request limit for the day.", {
|
||||
status: 429,
|
||||
headers: {
|
||||
"X-RateLimit-Limit": limit.toString(),
|
||||
"X-RateLimit-Remaining": remaining.toString(),
|
||||
"X-RateLimit-Reset": reset.toString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// create a threadId if one wasn't provided
|
||||
const threadId = input.threadId ?? (await openai.beta.threads.create()).id;
|
||||
|
||||
// Add a message to the thread
|
||||
const createdMessage = await openai.beta.threads.messages.create(threadId, {
|
||||
role: "user",
|
||||
content: input.message,
|
||||
});
|
||||
|
||||
// select the assistantId based on the isPublic flag
|
||||
const assistantId = input.isPublic
|
||||
? (process.env.OAI_PUBLIC_ASSISTANT_ID as string)
|
||||
: (process.env.OAI_ASSISTANT_ID as string);
|
||||
|
||||
return experimental_AssistantResponse(
|
||||
{
|
||||
threadId,
|
||||
messageId: createdMessage.id,
|
||||
},
|
||||
async ({ threadId, sendMessage }) => {
|
||||
// Run the assistant on the thread
|
||||
const run = await openai.beta.threads.runs.create(threadId, {
|
||||
assistant_id: assistantId!,
|
||||
});
|
||||
|
||||
async function waitForRun(run: Run) {
|
||||
// Poll for status change
|
||||
while (run.status === "queued" || run.status === "in_progress") {
|
||||
// delay for 500ms:
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
run = await openai.beta.threads.runs.retrieve(threadId!, run.id);
|
||||
}
|
||||
|
||||
// Check the run status
|
||||
if (
|
||||
run.status === "cancelled" ||
|
||||
run.status === "cancelling" ||
|
||||
run.status === "failed" ||
|
||||
run.status === "expired"
|
||||
) {
|
||||
throw new Error(run.status);
|
||||
}
|
||||
}
|
||||
|
||||
await waitForRun(run);
|
||||
|
||||
// Get new thread messages (after our message)
|
||||
const responseMessages = (
|
||||
await openai.beta.threads.messages.list(threadId, {
|
||||
after: createdMessage.id,
|
||||
order: "asc",
|
||||
})
|
||||
).data;
|
||||
|
||||
// Send the messages
|
||||
for (const message of responseMessages) {
|
||||
sendMessage({
|
||||
id: message.id,
|
||||
role: "assistant",
|
||||
content: message.content.filter(
|
||||
(content) => content.type === "text",
|
||||
) as Array<MessageContentText>,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,159 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
|
||||
import { errorhandler } from "@/lib/errorHandler";
|
||||
import { getFile } from "@/lib/files/get-file";
|
||||
import { openai } from "@/lib/openai";
|
||||
import prisma from "@/lib/prisma";
|
||||
|
||||
import { authOptions } from "../auth/[...nextauth]";
|
||||
|
||||
export default async function handle(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === "POST") {
|
||||
// POST /api/assistants
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
return res.status(401).end("Unauthorized");
|
||||
}
|
||||
|
||||
const { documentId } = req.body as { documentId: string };
|
||||
|
||||
try {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
select: {
|
||||
assistantEnabled: true,
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
fileId: true,
|
||||
file: true,
|
||||
storageType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
res.status(400).json("No document found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.assistantEnabled) {
|
||||
res.status(200).json("Assistant Already Enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// Upload the file to OpenAI
|
||||
const fileId = (
|
||||
await openai.files.create({
|
||||
file: await fetch(
|
||||
await getFile({
|
||||
type: document.versions[0].storageType,
|
||||
data: document.versions[0].file,
|
||||
}),
|
||||
),
|
||||
purpose: "assistants",
|
||||
})
|
||||
).id;
|
||||
|
||||
// Update the document and documentVersion in the database
|
||||
await prisma.documentVersion.update({
|
||||
where: {
|
||||
id: document.versions[0].id,
|
||||
},
|
||||
data: {
|
||||
fileId: fileId,
|
||||
document: {
|
||||
update: {
|
||||
assistantEnabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json("Assistant Enabled");
|
||||
return;
|
||||
} catch (error) {
|
||||
errorhandler(error, res);
|
||||
}
|
||||
} else if (req.method === "DELETE") {
|
||||
// DELETE /api/assistants
|
||||
const session = await getServerSession(req, res, authOptions);
|
||||
if (!session) {
|
||||
return res.status(401).end("Unauthorized");
|
||||
}
|
||||
|
||||
const { documentId } = req.body as { documentId: string };
|
||||
|
||||
try {
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id: documentId,
|
||||
},
|
||||
select: {
|
||||
assistantEnabled: true,
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
take: 1,
|
||||
select: {
|
||||
id: true,
|
||||
fileId: true,
|
||||
file: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
res.status(400).json("No document found");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!document.assistantEnabled) {
|
||||
res.status(200).json("Assistant is already disabled");
|
||||
return;
|
||||
}
|
||||
|
||||
// get the open AI file Id from db
|
||||
const fileId = document.versions[0].fileId;
|
||||
|
||||
if (fileId) {
|
||||
//deleting the file from openai
|
||||
await openai.files.del(fileId);
|
||||
}
|
||||
|
||||
// Update the document and documentVersion in the database
|
||||
await prisma.documentVersion.update({
|
||||
where: {
|
||||
id: document.versions[0].id,
|
||||
},
|
||||
data: {
|
||||
fileId: null,
|
||||
document: {
|
||||
update: {
|
||||
assistantEnabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.status(200).json("Assistant Disabled");
|
||||
return;
|
||||
} catch (error) {
|
||||
errorhandler(error, res);
|
||||
}
|
||||
} else {
|
||||
// We only allow POST and DELETE requests
|
||||
res.setHeader("Allow", ["POST", "DELETE"]);
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { errorhandler } from "@/lib/errorHandler";
|
||||
import { openai } from "@/lib/openai";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { convertThreadMessagesToMessages } from "@/lib/utils";
|
||||
|
||||
export default async function handle(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method === "POST") {
|
||||
// TODO: block unauthorized requests
|
||||
|
||||
const { documentId, userId } = req.body as {
|
||||
documentId: string;
|
||||
userId: string;
|
||||
};
|
||||
|
||||
try {
|
||||
let chat;
|
||||
chat = await prisma.chat.findUnique({
|
||||
where: {
|
||||
userId_documentId: {
|
||||
userId,
|
||||
documentId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
threadId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
// get fileId from document version
|
||||
const documentVersion = await prisma.documentVersion.findFirst({
|
||||
where: {
|
||||
documentId,
|
||||
isPrimary: true,
|
||||
},
|
||||
select: {
|
||||
fileId: true,
|
||||
},
|
||||
});
|
||||
|
||||
const threadId = (
|
||||
await openai.beta.threads.create({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Initializing chat with Papermark Assistant",
|
||||
file_ids: [documentVersion?.fileId || ""],
|
||||
metadata: { intitialMessage: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
).id;
|
||||
|
||||
chat = await prisma.chat.create({
|
||||
data: {
|
||||
userId,
|
||||
threadId,
|
||||
documentId,
|
||||
},
|
||||
select: {
|
||||
threadId: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const threadId = chat.threadId;
|
||||
// get existing messages from thread, latest first (DESC)
|
||||
const { data } = await openai.beta.threads.messages.list(threadId);
|
||||
|
||||
return res.status(200).json({
|
||||
...chat,
|
||||
messages: convertThreadMessagesToMessages(data),
|
||||
});
|
||||
} catch (error) {
|
||||
errorhandler(error, res);
|
||||
}
|
||||
} else {
|
||||
// We only allow GET and DELETE requests
|
||||
res.setHeader("Allow", ["POST"]);
|
||||
return res.status(405).end(`Method ${req.method} Not Allowed`);
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { authOptions } from "@/pages/api/auth/[...nextauth]";
|
||||
import { type Message } from "ai/react";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { usePlausible } from "next-plausible";
|
||||
|
||||
import { Chat } from "@/components/chat/chat";
|
||||
import Sparkle from "@/components/shared/icons/sparkle";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { getFile } from "@/lib/files/get-file";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { CustomUser } from "@/lib/types";
|
||||
|
||||
export const getServerSideProps = async (context: any) => {
|
||||
const { id } = context.params;
|
||||
const session = await getServerSession(context.req, context.res, authOptions);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/login?next=/documents/${id}/chat`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const userId = (session.user as CustomUser).id;
|
||||
|
||||
const document = await prisma.document.findUnique({
|
||||
where: {
|
||||
id,
|
||||
assistantEnabled: true,
|
||||
team: {
|
||||
users: {
|
||||
some: {
|
||||
userId: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
assistantEnabled: true,
|
||||
versions: {
|
||||
where: { isPrimary: true },
|
||||
select: {
|
||||
pages: {
|
||||
where: { pageNumber: 1 },
|
||||
select: {
|
||||
file: true,
|
||||
storageType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!document) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
// create or fetch threadId
|
||||
const res = await fetch(
|
||||
`${process.env.NEXTAUTH_URL}/api/assistants/threads`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
documentId: document.id,
|
||||
userId: userId,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
}
|
||||
|
||||
const { threadId, messages } = await res.json();
|
||||
|
||||
const firstPage = document.versions[0].pages[0]
|
||||
? await getFile({
|
||||
type: document.versions[0].pages[0].storageType,
|
||||
data: document.versions[0].pages[0].file,
|
||||
})
|
||||
: "";
|
||||
|
||||
return {
|
||||
props: {
|
||||
threadId,
|
||||
messages: messages || [],
|
||||
firstPage,
|
||||
userId,
|
||||
documentId: document.id,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export default function ChatPage({
|
||||
threadId,
|
||||
messages,
|
||||
firstPage,
|
||||
userId,
|
||||
documentId,
|
||||
}: {
|
||||
threadId: string;
|
||||
messages: Message[];
|
||||
firstPage: string;
|
||||
userId: string;
|
||||
documentId: string;
|
||||
}) {
|
||||
const { plan } = usePlan();
|
||||
const plausible = usePlausible();
|
||||
|
||||
useEffect(() => {
|
||||
plausible("assistantViewedFromDocument", {
|
||||
props: { documentId: documentId },
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav documentId={documentId} />
|
||||
<Chat
|
||||
initialMessages={messages}
|
||||
threadId={threadId}
|
||||
firstPage={firstPage}
|
||||
userId={userId}
|
||||
plan={plan}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Nav({ documentId }: { documentId: string }) {
|
||||
return (
|
||||
<nav className="fixed inset-x-0 top-0 z-10 bg-black">
|
||||
<div className="mx-auto px-2 sm:px-6 lg:px-8">
|
||||
<div className="relative flex h-16 items-center justify-between">
|
||||
<div className="flex flex-1 items-center justify-center sm:items-stretch sm:justify-start">
|
||||
<div className="flex flex-shrink-0 items-center gap-x-2">
|
||||
<p className="text-2xl font-bold tracking-tighter text-white">
|
||||
Papermark
|
||||
</p>
|
||||
<Sparkle className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 right-0 flex items-center pr-2 sm:static sm:inset-auto sm:ml-6 sm:pr-0">
|
||||
<Link href={`/documents/${documentId}`}>
|
||||
<Button variant="secondary">Back to document</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTeam } from "@/context/team-context";
|
||||
import { toast } from "sonner";
|
||||
import useSWR, { mutate } from "swr";
|
||||
|
||||
import { UpgradePlanModal } from "@/components/billing/upgrade-plan-modal";
|
||||
import DocumentHeader from "@/components/documents/document-header";
|
||||
import AppLayout from "@/components/layouts/app";
|
||||
import { NavMenu } from "@/components/navigation-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import LoadingSpinner from "@/components/ui/loading-spinner";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
import { usePlan } from "@/lib/swr/use-billing";
|
||||
import { useDocument } from "@/lib/swr/use-document";
|
||||
import { cn, fetcher } from "@/lib/utils";
|
||||
|
||||
type Feedback = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
enabled: boolean;
|
||||
data: {
|
||||
question: string;
|
||||
type: string;
|
||||
};
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export default function Settings() {
|
||||
const { document, primaryVersion } = useDocument();
|
||||
const { plan, isBusiness } = usePlan();
|
||||
const teamInfo = useTeam();
|
||||
const teamId = teamInfo?.currentTeam?.id;
|
||||
const id = document?.id;
|
||||
|
||||
// const { data: feedback } = useSWR<Feedback>(
|
||||
// teamId && id && `/api/teams/${teamId}/documents/${id}/feedback`,
|
||||
// fetcher,
|
||||
// {
|
||||
// dedupingInterval: 1000 * 60 * 60,
|
||||
// },
|
||||
// );
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||
const [value, setValue] = useState<string>("");
|
||||
|
||||
// useEffect(() => {
|
||||
// setValue(feedback?.data.question || "");
|
||||
// }, [feedback]);
|
||||
|
||||
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">
|
||||
{document && primaryVersion ? (
|
||||
<>
|
||||
{/* Action Header */}
|
||||
<DocumentHeader
|
||||
primaryVersion={primaryVersion}
|
||||
prismaDocument={document}
|
||||
teamId={teamInfo?.currentTeam?.id!}
|
||||
/>
|
||||
|
||||
<NavMenu
|
||||
navigation={[
|
||||
{
|
||||
label: "Overview",
|
||||
href: `/documents/${document.id}`,
|
||||
segment: `${document.id}`,
|
||||
},
|
||||
{
|
||||
label: "Settings",
|
||||
href: `/documents/${document.id}/settings`,
|
||||
segment: "settings",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Settings */}
|
||||
<div className="mx-auto grid w-full gap-2">
|
||||
<h1 className="text-2xl font-semibold">Settings</h1>
|
||||
</div>
|
||||
<div className="mx-auto grid w-full items-start gap-6 md:grid-cols-[180px_1fr] lg:grid-cols-[250px_1fr]">
|
||||
<nav className="grid gap-4 text-sm text-muted-foreground">
|
||||
<Link href="#" className="font-semibold text-primary">
|
||||
Feedback
|
||||
</Link>
|
||||
</nav>
|
||||
<div className="grid gap-6">
|
||||
{/* <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Document Name</CardTitle>
|
||||
<CardDescription>
|
||||
Used to identify your document.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form>
|
||||
<Input placeholder="Document Name" />
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t px-6 py-3 bg-muted rounded-b-lg justify-end">
|
||||
<Button>Save</Button>
|
||||
</CardFooter>
|
||||
</Card> */}
|
||||
{/* <Card>
|
||||
<CardHeader className="relative">
|
||||
<CardTitle>Feedback Question</CardTitle>
|
||||
<CardDescription>
|
||||
This question will be shown to visitors after the last
|
||||
page of your document.
|
||||
</CardDescription>
|
||||
<div className="absolute right-8 top-6">
|
||||
<span
|
||||
className="relative ml-auto flex h-4 w-4"
|
||||
title={`Feedback is ${feedback?.enabled ? "" : "not"} active`}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full rounded-full opacity-75",
|
||||
feedback?.enabled
|
||||
? "animate-ping bg-green-400"
|
||||
: "",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"relative inline-flex rounded-full h-4 w-4",
|
||||
feedback?.enabled ? "bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<span className="sr-only">
|
||||
{feedback?.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<form
|
||||
onSubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (value == "" || isNotBusiness) return null;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/documents/${id}/feedback`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ questionText: value }),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
await mutate(
|
||||
`/api/teams/${teamId}/documents/${id}/feedback`,
|
||||
);
|
||||
toast.success(
|
||||
"Successfully added a feedback question!",
|
||||
);
|
||||
} else {
|
||||
const { error } = await response.json();
|
||||
toast.error(error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle any errors that might occur during fetch
|
||||
toast.error(
|
||||
"An error occurred while adding the question.",
|
||||
);
|
||||
console.error("Fetch error:", error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<CardContent>
|
||||
<div className="grid w-full items-start gap-6 overflow-x-visible pb-4 pt-0">
|
||||
<div className="grid gap-3">
|
||||
<Label>Question Type</Label>
|
||||
<Select defaultValue="yes-no">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a question type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="yes-no">Yes / No</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-3">
|
||||
<Label htmlFor="question">Question</Label>
|
||||
<Input
|
||||
id="question"
|
||||
type="text"
|
||||
name="question"
|
||||
required={!isNotBusiness}
|
||||
placeholder="Are you interested?"
|
||||
value={value || ""}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t py-3 bg-muted rounded-b-lg justify-end gap-x-2">
|
||||
{feedback ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
loading={loadingStatus}
|
||||
onClick={async (e) => {
|
||||
try {
|
||||
e.preventDefault();
|
||||
setLoadingStatus(true);
|
||||
|
||||
const response = await fetch(
|
||||
`/api/teams/${teamId}/documents/${id}/feedback`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
enabled: !feedback?.enabled,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
await mutate(
|
||||
`/api/teams/${teamId}/documents/${id}/feedback`,
|
||||
);
|
||||
toast.success(
|
||||
`${feedback?.enabled ? "Turned off" : "Turned on"} feedback question`,
|
||||
);
|
||||
} else {
|
||||
const { error } = await response.json();
|
||||
toast.error(error.message);
|
||||
}
|
||||
} catch (error) {
|
||||
// Handle any errors that might occur during fetch
|
||||
toast.error("An error occurred.");
|
||||
console.error("Fetch error:", error);
|
||||
} finally {
|
||||
setLoadingStatus(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{feedback?.enabled ? "Turn off" : "Turn on"}
|
||||
</Button>
|
||||
) : null}
|
||||
{isNotBusiness ? (
|
||||
<UpgradePlanModal
|
||||
clickedPlan={"Business"}
|
||||
trigger={"feedback_question"}
|
||||
>
|
||||
<Button type="submit" loading={loading}>
|
||||
{feedback ? "Update question" : "Create question"}
|
||||
</Button>
|
||||
</UpgradePlanModal>
|
||||
) : (
|
||||
<Button type="submit" loading={loading}>
|
||||
{feedback ? "Update question" : "Create question"}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</form>
|
||||
</Card> */}
|
||||
{/* <Card className="border-red-500">
|
||||
<CardHeader>
|
||||
<CardTitle>Delete Document</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This actions deletes the document and any associates links
|
||||
and analytics.
|
||||
</p>
|
||||
</CardContent>
|
||||
<CardFooter className="border-t px-6 py-3 border-red-500 rounded-b-lg justify-end">
|
||||
<Button variant="destructive">Delete document</Button>
|
||||
</CardFooter>
|
||||
</Card> */}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<LoadingSpinner className="mr-1 h-20 w-20" />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user