feat: implement subscription (#92)

This commit is contained in:
Tianzhou (天舟)
2023-05-18 22:04:27 +08:00
committed by GitHub
parent 666e2091f6
commit b0b6caaffa
43 changed files with 1077 additions and 315 deletions

15
auth.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
import { Subscription } from "@/types";
import "next-auth";
declare module "next-auth" {
interface User {
id: string;
usage: number;
stripeId: number;
subscription: Subscription;
}
interface Session {
user: User;
}
}

View File

@ -0,0 +1,38 @@
/*
Warnings:
- Added the required column `plan` to the `subscription` table without a default value. This is not possible if the table is not empty.
*/
-- CreateEnum
CREATE TYPE "SubscriptionPlan" AS ENUM ('PRO');
-- AlterTable
ALTER TABLE "subscription" ADD COLUMN "amount" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "currency" TEXT NOT NULL DEFAULT '',
ADD COLUMN "description" TEXT NOT NULL DEFAULT '',
ADD COLUMN "email" TEXT NOT NULL DEFAULT '',
ADD COLUMN "plan" "SubscriptionPlan" NOT NULL,
ADD COLUMN "receipt" TEXT NOT NULL DEFAULT '';
-- CreateTable
CREATE TABLE "usage" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"end_user" TEXT NOT NULL DEFAULT '',
"count" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "usage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "usage_created_at_idx" ON "usage"("created_at");
-- CreateIndex
CREATE INDEX "usage_end_user_idx" ON "usage"("end_user");
-- CreateIndex
CREATE INDEX "subscription_user_id_idx" ON "subscription"("user_id");
-- CreateIndex
CREATE INDEX "subscription_email_idx" ON "subscription"("email");

View File

@ -43,18 +43,38 @@ model Message {
@@map("message") @@map("message")
} }
model Usage {
id String @id @default(cuid())
createdAt DateTime @default(now()) @map("created_at")
endUser String @default("") @map("end_user")
count Int @default(0)
@@index([createdAt], map: "usage_created_at_idx")
@@index([endUser], map: "usage_end_user_idx")
@@map("usage")
}
model Subscription { model Subscription {
id String @id @default(cuid()) id String @id @default(cuid())
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])
userId String @default("") @map("user_id") userId String @default("") @map("user_id")
createdAt DateTime @default(now()) @map("created_at") // Denormalize to avoid join with the user table
status SubscriptionStatus @default(ACTIVE) email String @default("") @map("email")
startAt DateTime @default(now()) @map("start_at") createdAt DateTime @default(now()) @map("created_at")
expireAt DateTime @default(now()) @map("expire_at") status SubscriptionStatus @default(ACTIVE)
paymentId String @default("") @map("payment_id") startAt DateTime @default(now()) @map("start_at")
customerId String @default("") @map("customer_id") expireAt DateTime @default(now()) @map("expire_at")
paymentId String @default("") @map("payment_id")
customerId String @default("") @map("customer_id")
plan SubscriptionPlan
description String @default("")
amount Int @default(0)
currency String @default("")
receipt String @default("")
@@unique([paymentId]) @@unique([paymentId])
@@index([userId], map: "subscription_user_id_idx")
@@index([email], map: "subscription_email_idx")
@@map("subscription") @@map("subscription")
} }
@ -63,6 +83,10 @@ enum SubscriptionStatus {
CANCELED CANCELED
} }
enum SubscriptionPlan {
PRO
}
// NextAuth Prisma Schema Begin // NextAuth Prisma Schema Begin
// Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow // Below are the auth related prisma schema to support NextAuth.js. In partiular, the email flow
// requires this. https://authjs.dev/reference/adapter/prisma // requires this. https://authjs.dev/reference/adapter/prisma

7
process.d.ts vendored
View File

@ -9,9 +9,6 @@ declare namespace NodeJS {
OPENAI_API_KEY: string; OPENAI_API_KEY: string;
// Optional. OpenAI API endpoint. Defaults to https://api.openai.com. // Optional. OpenAI API endpoint. Defaults to https://api.openai.com.
OPENAI_API_ENDPOINT: string; OPENAI_API_ENDPOINT: string;
// Optional. API key to protect the backend API endpoint.
// This needs to be exposed to the frontend and must be prefixed with NEXT_PUBLIC_.
NEXT_PUBLIC_API_KEY: string;
// Optional. NextAuth.js URL. Defaults to the current domain. // Optional. NextAuth.js URL. Defaults to the current domain.
NEXTAUTH_URL: string; NEXTAUTH_URL: string;
// Optional. NextAuth.js secret. Defaults to a randomly generated string. // Optional. NextAuth.js secret. Defaults to a randomly generated string.
@ -33,7 +30,7 @@ declare namespace NodeJS {
STRIPE_API_KEY: string; STRIPE_API_KEY: string;
// Optional. Stripe webhook secret. // Optional. Stripe webhook secret.
STRIPE_WEBHOOK_SECRET: string; STRIPE_WEBHOOK_SECRET: string;
// Optional. Stripe annual license price id. // Optional. Stripe price id for Pro plan 1 year subscription.
STRIPE_ANNUAL_LICENSE_PRICE_ID: string; STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION: string;
} }
} }

View File

@ -1,10 +1,12 @@
import { signIn, useSession } from "next-auth/react"; import { signIn, useSession } from "next-auth/react";
import Link from "next/link"; import Link from "next/link";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import SubscriptionHistoryTable from "./SubscriptionHistoryTable";
import { getDateString } from "@/utils";
const AccountView = () => { const AccountView = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: session, status } = useSession(); const { data: session } = useSession();
return ( return (
<> <>
@ -17,21 +19,51 @@ const AccountView = () => {
</button> </button>
)} )}
{session?.user && ( {session?.user && (
<div className="flex items-center space-x-2"> <div>
{session.user.image && ( <div className="flex-col space-y-4 sm:space-y-0 sm:flex sm:flex-row justify-between">
<img <div className="flex items-center space-x-2">
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer" {session.user.image && (
src={session.user.image} <img
alt="" className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700 cursor-pointer"
/> src={session.user.image}
)} alt=""
<span>{session.user.email ?? session.user.name}</span> />
<Link )}
href="/api/auth/signout" <span>{session.user.email ?? session.user.name}</span>
className="whitespace-nowrap rounded bg-indigo-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" <Link
> href="/api/auth/signout"
{t("common.sign-out")} className="whitespace-nowrap rounded bg-indigo-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
</Link> >
{t("common.sign-out")}
</Link>
</div>
<div className="flex text-base font-semibold tracking-tight items-center">
<span
className={`${
session?.user.subscription.plan == "PRO"
? "ring-green-600/2 bg-green-50 text-green-700"
: "ring-gray-600/2 bg-gray-50 text-gray-700"
} rounded-full px-3 py-1 ring-1 ring-inset`}
>
{t(
`setting.plan.${session.user.subscription.plan.toLowerCase()}`
)}
</span>
<div className="ml-4">
{t("setting.plan.n-question-per-month", {
count: session.user.subscription.quota,
})}
</div>
{session.user.subscription.plan === "PRO" && (
<div className="ml-8">
{getDateString(session.user.subscription.startAt)}
{` - `}
{getDateString(session.user.subscription.expireAt)}
</div>
)}
</div>
</div>
<SubscriptionHistoryTable />
</div> </div>
)} )}
</> </>

View File

@ -6,6 +6,7 @@ import {
useConversationStore, useConversationStore,
useLayoutStore, useLayoutStore,
ResponsiveWidth, ResponsiveWidth,
useSettingStore,
} from "@/store"; } from "@/store";
import { Table } from "@/types"; import { Table } from "@/types";
import useLoading from "@/hooks/useLoading"; import useLoading from "@/hooks/useLoading";
@ -14,14 +15,16 @@ import Icon from "./Icon";
import DarkModeSwitch from "./DarkModeSwitch"; import DarkModeSwitch from "./DarkModeSwitch";
import ConversationList from "./Sidebar/ConversationList"; import ConversationList from "./Sidebar/ConversationList";
import ConnectionList from "./Sidebar/ConnectionList"; import ConnectionList from "./Sidebar/ConnectionList";
import QuotaWidget from "./QuotaWidget"; import QuotaView from "./QuotaView";
import { HasFeature } from "../utils"; import { hasFeature } from "../utils";
import MultipleSelect from "./kit/MultipleSelect"; import MultipleSelect from "./kit/MultipleSelect";
import SettingAvatarIcon from "./SettingAvatarIcon";
interface State {} interface State {}
const ConnectionSidebar = () => { const ConnectionSidebar = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const settingStore = useSettingStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
const conversationStore = useConversationStore(); const conversationStore = useConversationStore();
@ -142,8 +145,9 @@ const ConnectionSidebar = () => {
<div className="w-full flex flex-col justify-start items-start"> <div className="w-full flex flex-col justify-start items-start">
<ConnectionList /> <ConnectionList />
</div> </div>
<div className="w-full flex flex-col justify-end items-center"> <div className="w-full flex flex-col space-y-2 justify-end items-center">
<DarkModeSwitch /> <DarkModeSwitch />
<SettingAvatarIcon />
</div> </div>
</div> </div>
<div className="relative p-4 pb-0 w-64 h-full overflow-y-auto flex flex-col justify-start items-start bg-gray-100 dark:bg-zinc-700"> <div className="relative p-4 pb-0 w-64 h-full overflow-y-auto flex flex-col justify-start items-start bg-gray-100 dark:bg-zinc-700">
@ -224,19 +228,12 @@ const ConnectionSidebar = () => {
<ConversationList /> <ConversationList />
</div> </div>
<div className="sticky bottom-0 w-full flex flex-col justify-center bg-gray-100 dark:bg-zinc-700 backdrop-blur bg-opacity-60 pb-4 py-2"> <div className="sticky bottom-0 w-full flex flex-col justify-center bg-gray-100 dark:bg-zinc-700 backdrop-blur bg-opacity-60 pb-4 py-2">
{HasFeature("quota") && ( {!settingStore.setting.openAIApiConfig?.key &&
<div className="mb-4"> hasFeature("quota") && (
<QuotaWidget /> <div className="mb-4">
</div> <QuotaView />
)} </div>
<a )}
href="https://discord.gg/z6kakemDjm"
className="text-indigo-600 dark:text-indigo-400 text-sm font-medium flex flex-row justify-center items-center mb-2 hover:underline"
target="_blank"
>
<Icon.BsDiscord className="w-4 h-auto mr-1" />
{t("social.join-discord-channel")}
</a>
<a <a
className="dark:hidden" className="dark:hidden"
href="https://www.producthunt.com/posts/sql-chat-2?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-sql&#0045;chat&#0045;2" href="https://www.producthunt.com/posts/sql-chat-2?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-sql&#0045;chat&#0045;2"

View File

@ -5,7 +5,6 @@ import useDarkMode from "@/hooks/useDarkmode";
import Icon from "../Icon"; import Icon from "../Icon";
import GitHubStarBadge from "../GitHubStarBadge"; import GitHubStarBadge from "../GitHubStarBadge";
import SettingAvatarIcon from "../SettingAvatarIcon";
interface Props { interface Props {
className?: string; className?: string;
@ -60,7 +59,6 @@ const Header = (props: Props) => {
alt="" alt=""
/> />
</a> </a>
<SettingAvatarIcon />
</div> </div>
</div> </div>
); );

View File

@ -17,6 +17,7 @@ import Icon from "../Icon";
import { CodeBlock } from "../CodeBlock"; import { CodeBlock } from "../CodeBlock";
import EngineIcon from "../EngineIcon"; import EngineIcon from "../EngineIcon";
import ThreeDotsLoader from "./ThreeDotsLoader"; import ThreeDotsLoader from "./ThreeDotsLoader";
import { useSession } from "next-auth/react";
interface Props { interface Props {
message: Message; message: Message;
@ -24,6 +25,7 @@ interface Props {
const MessageView = (props: Props) => { const MessageView = (props: Props) => {
const message = props.message; const message = props.message;
const { data: session } = useSession();
const { t } = useTranslation(); const { t } = useTranslation();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const userStore = useUserStore(); const userStore = useUserStore();
@ -84,8 +86,24 @@ const MessageView = (props: Props) => {
{message.content} {message.content}
</div> </div>
</div> </div>
<div className="w-10 h-10 p-1 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0"> <div className="w-10 h-10 border dark:border-zinc-700 rounded-full flex justify-center items-center ml-2 shrink-0">
<Icon.AiOutlineUser className="w-6 h-6" /> {session?.user ? (
session.user.image ? (
<img
className="inline-block h-8 w-8 rounded-full hover:bg-gray-100 dark:hover:bg-zinc-700"
src={session.user.image}
alt=""
/>
) : (
<div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase">
{session.user.name
? session.user.name.charAt(0)
: session.user.email?.charAt(0)}
</div>
)
) : (
<Icon.AiOutlineUser className="w-6 h-6" />
)}
</div> </div>
</> </>
) : ( ) : (

View File

@ -2,7 +2,6 @@ import axios from "axios";
import { head, last } from "lodash-es"; import { head, last } from "lodash-es";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { toast } from "react-hot-toast"; import { toast } from "react-hot-toast";
import { API_KEY } from "@/env";
import { import {
getAssistantById, getAssistantById,
getPromptGeneratorOfAssistant, getPromptGeneratorOfAssistant,
@ -22,12 +21,15 @@ import ClearConversationButton from "../ClearConversationButton";
import MessageTextarea from "./MessageTextarea"; import MessageTextarea from "./MessageTextarea";
import DataStorageBanner from "../DataStorageBanner"; import DataStorageBanner from "../DataStorageBanner";
import QuotaOverflowBanner from "../QuotaOverflowBanner"; import QuotaOverflowBanner from "../QuotaOverflowBanner";
import { useSession } from "next-auth/react";
import getEventEmitter from "@/utils/event-emitter";
// The maximum number of tokens that can be sent to the OpenAI API. // The maximum number of tokens that can be sent to the OpenAI API.
// reference: https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens // reference: https://platform.openai.com/docs/api-reference/completions/create#completions/create-max_tokens
const MAX_TOKENS = 4000; const MAX_TOKENS = 4000;
const ConversationView = () => { const ConversationView = () => {
const { data: session } = useSession();
const settingStore = useSettingStore(); const settingStore = useSettingStore();
const layoutStore = useLayoutStore(); const layoutStore = useLayoutStore();
const connectionStore = useConnectionStore(); const connectionStore = useConnectionStore();
@ -182,10 +184,12 @@ const ConversationView = () => {
const tableList: string[] = []; const tableList: string[] = [];
if (currentConversation.selectedTablesName) { if (currentConversation.selectedTablesName) {
currentConversation.selectedTablesName.forEach((tableName) => { currentConversation.selectedTablesName.forEach(
const table = tables.find((table) => table.name === tableName); (tableName: string) => {
tableList.push(table!.structure); const table = tables.find((table) => table.name === tableName);
}); tableList.push(table!.structure);
}
);
} else { } else {
for (const table of tables) { for (const table of tables) {
tableList.push(table!.structure); tableList.push(table!.structure);
@ -235,14 +239,21 @@ const ConversationView = () => {
}); });
const requestHeaders: any = {}; const requestHeaders: any = {};
if (API_KEY) { if (session?.user.id) {
requestHeaders["Authorization"] = `Bearer ${API_KEY}`; requestHeaders["Authorization"] = `Bearer ${session?.user.id}`;
}
if (settingStore.setting.openAIApiConfig?.key) {
requestHeaders["x-openai-key"] =
settingStore.setting.openAIApiConfig?.key;
}
if (settingStore.setting.openAIApiConfig?.endpoint) {
requestHeaders["x-openai-endpoint"] =
settingStore.setting.openAIApiConfig?.endpoint;
} }
const rawRes = await fetch("/api/chat", { const rawRes = await fetch("/api/chat", {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
messages: formatedMessageList, messages: formatedMessageList,
openAIApiConfig: settingStore.setting.openAIApiConfig,
}), }),
headers: requestHeaders, headers: requestHeaders,
}); });
@ -307,6 +318,9 @@ const ConversationView = () => {
status: "DONE", status: "DONE",
}); });
// Emit usage update event so quota widget can update.
getEventEmitter().emit("usage.update");
// Collect system prompt // Collect system prompt
// We only collect the db prompt for the system prompt. We do not collect the intermediate // We only collect the db prompt for the system prompt. We do not collect the intermediate
// exchange to save space since those can be derived from the previous record. // exchange to save space since those can be derived from the previous record.
@ -324,10 +338,18 @@ const ConversationView = () => {
usageMessageList.push(assistantMessage); usageMessageList.push(assistantMessage);
axios axios
.post<string[]>("/api/usage", { .post<string[]>(
conversation: currentConversation, "/api/collect",
messages: usageMessageList, {
}) conversation: currentConversation,
messages: usageMessageList,
},
{
headers: session?.user.id
? { Authorization: `Bearer ${session?.user.id}` }
: undefined,
}
)
.catch(() => { .catch(() => {
// do nth // do nth
}); });
@ -341,7 +363,7 @@ const ConversationView = () => {
} relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white dark:bg-zinc-800`} } relative w-full h-full max-h-full flex flex-col justify-start items-start overflow-y-auto bg-white dark:bg-zinc-800`}
> >
<div className="sticky top-0 z-1 bg-white dark:bg-zinc-800 w-full flex flex-col justify-start items-start"> <div className="sticky top-0 z-1 bg-white dark:bg-zinc-800 w-full flex flex-col justify-start items-start">
<QuotaOverflowBanner /> {!settingStore.setting.openAIApiConfig?.key && <QuotaOverflowBanner />}
<DataStorageBanner /> <DataStorageBanner />
<Header className={showHeaderShadow ? "shadow" : ""} /> <Header className={showHeaderShadow ? "shadow" : ""} />
</div> </div>

View File

@ -0,0 +1,17 @@
import { useSession } from "next-auth/react";
const PricingView = () => {
const { data: session } = useSession();
return (
<div className="bg-white dark:bg-zinc-800">
<span>Debug View</span>
<div className="pt-2 text-sm">Session Info</div>
<div className="whitespace-pre-wrap mx-auto max-w-7xl lg:flex lg:items-center lg:justify-between">
{JSON.stringify(session, null, "\t")}
</div>
</div>
);
};
export default PricingView;

View File

@ -4,6 +4,7 @@ import { useDebounce } from "react-use";
import { useSettingStore } from "@/store"; import { useSettingStore } from "@/store";
import { OpenAIApiConfig } from "@/types"; import { OpenAIApiConfig } from "@/types";
import TextField from "./kit/TextField"; import TextField from "./kit/TextField";
import { set } from "lodash-es";
const OpenAIApiConfigView = () => { const OpenAIApiConfigView = () => {
const { t } = useTranslation(); const { t } = useTranslation();
@ -11,6 +12,17 @@ const OpenAIApiConfigView = () => {
const [openAIApiConfig, setOpenAIApiConfig] = useState( const [openAIApiConfig, setOpenAIApiConfig] = useState(
settingStore.setting.openAIApiConfig settingStore.setting.openAIApiConfig
); );
const [maskKey, setMaskKey] = useState(true);
const maskedKey = (str: string) => {
if (str.length < 7) {
return str;
}
const firstThree = str.slice(0, 3);
const lastFour = str.slice(-4);
const middle = ".".repeat(str.length - 7);
return `${firstThree}${middle}${lastFour}`;
};
useDebounce( useDebounce(
() => { () => {
@ -25,6 +37,7 @@ const OpenAIApiConfigView = () => {
...openAIApiConfig, ...openAIApiConfig,
...config, ...config,
}); });
setMaskKey(false);
}; };
return ( return (
@ -34,7 +47,9 @@ const OpenAIApiConfigView = () => {
<label className="mb-1">OpenAI API Key</label> <label className="mb-1">OpenAI API Key</label>
<TextField <TextField
placeholder="OpenAI API Key" placeholder="OpenAI API Key"
value={openAIApiConfig.key} value={
maskKey ? maskedKey(openAIApiConfig.key) : openAIApiConfig.key
}
onChange={(value) => handleSetOpenAIApiConfig({ key: value })} onChange={(value) => handleSetOpenAIApiConfig({ key: value })}
/> />
</div> </div>

View File

@ -4,8 +4,6 @@ import { useTranslation } from "react-i18next";
import getStripe from "../utils/get-stripejs"; import getStripe from "../utils/get-stripejs";
import { fetchPostJSON } from "../utils/api-helpers"; import { fetchPostJSON } from "../utils/api-helpers";
const includedFeatures = ["Private forum access", "Member resources", "Entry to annual conference", "Official member t-shirt"];
const checkout = async () => { const checkout = async () => {
// Create a Checkout Session. // Create a Checkout Session.
const response = await fetchPostJSON("/api/checkout_sessions", {}); const response = await fetchPostJSON("/api/checkout_sessions", {});
@ -35,14 +33,23 @@ const PricingView = () => {
return ( return (
<div className="bg-white dark:bg-zinc-800"> <div className="bg-white dark:bg-zinc-800">
<span className="rounded-full bg-green-50 px-4 py-1.5 text-xl font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{"🎈 "} {t(`setting.plan.pro`)}
</span>
<div className="mx-auto max-w-7xl p-6 lg:flex lg:items-center lg:justify-between lg:px-8"> <div className="mx-auto max-w-7xl p-6 lg:flex lg:items-center lg:justify-between lg:px-8">
<h2 className="text-3xl font-bold tracking-tight sm:text-4xl">{t("setting.plan.pro-question-per-month")}</h2> <h2 className="text-3xl font-bold tracking-tight sm:text-4xl">
{t("setting.plan.n-question-per-month", {
count: 1000,
})}
</h2>
<div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0"> <div className="mt-10 flex items-center gap-x-6 lg:mt-0 lg:flex-shrink-0">
<button <button
className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600" className="rounded-md bg-indigo-600 px-3.5 py-2.5 text-xl font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => (session?.user?.email ? checkout() : signIn())} onClick={() => (session?.user?.email ? checkout() : signIn())}
> >
{session?.user?.email ? t("setting.plan.early-bird-checkout") : t("payment.login-to-buy")} {session?.user?.email
? t("setting.plan.early-bird-checkout")
: t("payment.sign-in-to-buy")}
</button> </button>
</div> </div>
</div> </div>

View File

@ -0,0 +1,102 @@
import { signIn, useSession } from "next-auth/react";
import axios from "axios";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Quota } from "@/types";
import getEventEmitter from "@/utils/event-emitter";
interface Props {
className?: string;
}
const QuotaView = (props: Props) => {
const [quota, setQuota] = useState<Quota>({ current: 0, limit: 0 });
const { t } = useTranslation();
const { data: session } = useSession();
const showSupplyOwnKey = !session || quota.current >= quota.limit;
const expired =
session?.user?.subscription?.expireAt &&
session?.user?.subscription?.expireAt < Date.now();
const showActionButton =
!session || session.user.subscription.plan === "FREE" || expired;
const refreshQuota = async (userId: string) => {
let quota: Quota = { current: 0, limit: 0 };
try {
const { data } = await axios.get("/api/usage", {
headers: { Authorization: `Bearer ${userId}` },
});
quota = data;
} catch (error) {
// do nth
}
setQuota(quota);
};
getEventEmitter().on("usage.update", () => {
if (session?.user.id) {
refreshQuota(session.user.id);
}
});
useEffect(() => {
if (session?.user.id) {
refreshQuota(session.user.id);
}
}, [session]);
return (
<div className="px-4 py-3 space-y-2 rounded-lg border border-indigo-400 flex flex-col dark:text-gray-300 hover:bg-white dark:hover:bg-zinc-800 bg-white dark:bg-zinc-800">
<div className="flex justify-between">
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{session
? t(`setting.plan.${session.user.subscription.plan.toLowerCase()}`)
: t("setting.plan.guest")}
</span>
{!!expired && (
<span className="rounded-full bg-yellow-50 px-2 py-0.5 text-xs font-medium text-yellow-700 ring-1 ring-inset ring-yellow-600/20">
{t("setting.plan.expired")}
</span>
)}
</div>
<div className="flex justify-between pt-1">
<div>{t("common.quota")}</div>
<div
className={
quota.current >= quota.limit ? "text-red-600" : "text-black"
}
>
{quota.current}/{quota.limit}
</div>
</div>
{!!showActionButton &&
(session ? (
<Link
href="/setting"
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{expired ? t("setting.plan.renew") : t("setting.plan.upgrade")}
</Link>
) : (
<button
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => signIn()}
>
{t("setting.plan.signup-for-more")}
</button>
))}
{!!showSupplyOwnKey && (
<Link
className="text-center rounded-full underline hover:opacity-80 px-2 py-0.5 text-xs font-medium text-gray-700"
href="/setting"
>
{t("banner.use-my-key")}
</Link>
)}
</div>
);
};
export default QuotaView;

View File

@ -1,64 +0,0 @@
import { signIn, useSession } from "next-auth/react";
import axios from "axios";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { Quota } from "@/types";
interface Props {
className?: string;
}
const QuotaWidget = (props: Props) => {
const [quota, setQuota] = useState<Quota>({ current: 0, limit: 0 });
const { t } = useTranslation();
const { data: session } = useSession();
useEffect(() => {
const refreshQuota = async () => {
let quota: Quota = { current: 0, limit: 0 };
try {
const { data } = await axios.get("/api/quota", {});
quota = data;
} catch (error) {
// do nth
}
setQuota(quota);
};
refreshQuota();
}, []);
return (
<div className="p-4 space-y-2 rounded-lg border border-indigo-400 flex flex-col dark:text-gray-300 hover:bg-white dark:hover:bg-zinc-800 bg-white dark:bg-zinc-800">
<div className="flex justify-start">
<span className="rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">
{t("setting.plan.free")}
</span>
</div>
<div className="flex justify-between">
<div>{t("common.quota")}</div>
<div>
{quota.current}/{quota.limit}
</div>
</div>
{session ? (
<Link
href="/setting"
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
>
{t("setting.plan.upgrade")}
</Link>
) : (
<button
className="rounded bg-indigo-600 px-2 py-1 text-center text-base font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600"
onClick={() => signIn()}
>
{t("setting.plan.signup-for-more")}
</button>
)}
</div>
);
};
export default QuotaWidget;

View File

@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import Tooltip from "./kit/Tooltip"; import Tooltip from "./kit/Tooltip";
import Icon from "./Icon"; import Icon from "./Icon";
import { HasFeature } from "../utils"; import { hasFeature } from "../utils";
interface Props {} interface Props {}
@ -14,7 +14,7 @@ const SettingAvatarIcon = (props: Props) => {
return ( return (
<Tooltip title={t("common.setting")} side="right"> <Tooltip title={t("common.setting")} side="right">
<div> <div>
{(!HasFeature("account") || !session) && ( {(!hasFeature("account") || !session) && (
<Link <Link
className=" w-10 h-10 p-1 rounded-full flex flex-row justify-center items-center hover:bg-gray-100 dark:hover:bg-zinc-700" className=" w-10 h-10 p-1 rounded-full flex flex-row justify-center items-center hover:bg-gray-100 dark:hover:bg-zinc-700"
data-tip={t("common.setting")} data-tip={t("common.setting")}
@ -23,7 +23,7 @@ const SettingAvatarIcon = (props: Props) => {
<Icon.IoMdSettings className="text-gray-600 dark:text-gray-300 w-6 h-auto" /> <Icon.IoMdSettings className="text-gray-600 dark:text-gray-300 w-6 h-auto" />
</Link> </Link>
)} )}
{HasFeature("account") && session?.user && ( {hasFeature("account") && session?.user && (
<Link href="/setting"> <Link href="/setting">
{session.user.image ? ( {session.user.image ? (
<img <img
@ -33,7 +33,9 @@ const SettingAvatarIcon = (props: Props) => {
/> />
) : ( ) : (
<div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase cursor-pointer"> <div className="bg-indigo-100 px-3 py-1 rounded-full text-indigo-600 hover:bg-indigo-200 uppercase cursor-pointer">
{session.user.name?.charAt(0)} {session.user.name
? session.user.name.charAt(0)
: session.user.email?.charAt(0)}
</div> </div>
)} )}
</Link> </Link>

View File

@ -1,8 +1,10 @@
import React from "react"; import React from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { HasFeature } from "../utils"; import { useSession } from "next-auth/react";
import { hasFeature } from "../utils";
import Icon from "./Icon"; import Icon from "./Icon";
import AccountView from "./AccountView"; import AccountView from "./AccountView";
import DebugView from "./DebugView";
import PricingView from "./PricingView"; import PricingView from "./PricingView";
import WeChatQRCodeView from "./WeChatQRCodeView"; import WeChatQRCodeView from "./WeChatQRCodeView";
import ClearDataButton from "./ClearDataButton"; import ClearDataButton from "./ClearDataButton";
@ -12,6 +14,7 @@ import OpenAIApiConfigView from "./OpenAIApiConfigView";
const SettingView = () => { const SettingView = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: session } = useSession();
return ( return (
<div className="w-full flex flex-col justify-start items-start space-y-3 pt-4 dark:bg-zinc-800"> <div className="w-full flex flex-col justify-start items-start space-y-3 pt-4 dark:bg-zinc-800">
@ -27,13 +30,19 @@ const SettingView = () => {
<WeChatQRCodeView /> <WeChatQRCodeView />
</div> </div>
{HasFeature("account") && ( {hasFeature("debug") && (
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
<DebugView />
</div>
)}
{hasFeature("account") && (
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2"> <div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
<AccountView /> <AccountView />
</div> </div>
)} )}
{HasFeature("payment") && ( {hasFeature("payment") && session?.user?.subscription.plan != "PRO" && (
<div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2"> <div className="w-full border border-gray-200 dark:border-zinc-700 p-4 rounded-lg space-y-2">
<PricingView /> <PricingView />
</div> </div>

View File

@ -0,0 +1,98 @@
import { useEffect, useState } from "react";
import axios from "axios";
import { useSession } from "next-auth/react";
import { SubscriptionPurchase } from "@/types";
import { getCurrencySymbol, getDateString } from "@/utils";
import { t } from "i18next";
const SubscriptionHistoryTable = () => {
const [list, setList] = useState<SubscriptionPurchase[]>([]);
const { data: session } = useSession();
useEffect(() => {
const refreshSubscriptionList = async (userId: string) => {
let list: SubscriptionPurchase[] = [];
try {
const { data } = await axios.get("/api/subscription", {
headers: { Authorization: `Bearer ${userId}` },
});
list = data;
} catch (error) {
// do nth
}
setList(list);
};
if (session?.user.id) {
refreshSubscriptionList(session.user.id);
}
}, [session]);
return (
<div className="px-4 sm:px-6 lg:px-8">
<div className="mt-8 flow-root">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8">
<table className="min-w-full divide-y divide-gray-300">
<thead>
<tr>
<th
scope="col"
className="py-3.5 pl-4 pr-3 text-left text-sm font-semibold text-gray-900 sm:pl-0"
>
{t("common.date")}
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
{t("common.description")}
</th>
<th
scope="col"
className="px-3 py-3.5 text-left text-sm font-semibold text-gray-900"
>
{t("common.amount")}
</th>
<th
scope="col"
className="relative py-3.5 pl-3 pr-4 sm:pr-0"
></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{list.map((subscription) => (
<tr key={subscription.id}>
<td className="whitespace-nowrap py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0">
{getDateString(subscription.createdAt)}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{subscription.description}
</td>
<td className="whitespace-nowrap px-3 py-4 text-sm text-gray-500">
{getCurrencySymbol(
subscription.currency.toLocaleUpperCase()
)}
{subscription.amount / 100}
</td>
<td className="relative whitespace-nowrap py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<a
href={subscription.receipt}
target="_blank"
className="text-indigo-600 hover:text-indigo-900"
>
{t("setting.billing.view-receipt")}
</a>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</div>
);
};
export default SubscriptionHistoryTable;

View File

@ -1,2 +0,0 @@
// API_KEY is using to limit those authorized to use the API and protect the API endpoint.
export const API_KEY = process.env.NEXT_PUBLIC_API_KEY || "";

View File

@ -13,7 +13,10 @@
"sign-in": "Sign in", "sign-in": "Sign in",
"sign-out": "Sign out", "sign-out": "Sign out",
"back": "Back", "back": "Back",
"quota": "Quota" "quota": "Monthly Quota",
"date": "Date",
"description": "Description",
"amount": "Amount"
}, },
"conversation": { "conversation": {
"new-chat": "New Chat", "new-chat": "New Chat",
@ -58,15 +61,19 @@
"self": "Setting", "self": "Setting",
"general": "General", "general": "General",
"plan": { "plan": {
"guest": "Guest",
"free": "Free", "free": "Free",
"pro": "Pro", "pro": "Pro",
"signup-for-more": "Sign up for more", "signup-for-more": "Sign up for more",
"upgrade": "Upgrade", "upgrade": "Upgrade",
"pro-question-per-month": "1000 questions / month", "renew": "Renew",
"expired": "Expired",
"n-question-per-month": "{{count}} questions / month",
"early-bird-checkout": "Early bird discount, 50% off for 1 year" "early-bird-checkout": "Early bird discount, 50% off for 1 year"
}, },
"billing": { "billing": {
"self": "Billing" "self": "Billing",
"view-receipt": "View receipt"
}, },
"account": { "account": {
"self": "Account" "self": "Account"
@ -102,6 +109,6 @@
}, },
"payment": { "payment": {
"self": "Payment", "self": "Payment",
"login-to-buy": "Login to buy" "sign-in-to-buy": "Sign in to buy"
} }
} }

View File

@ -9,11 +9,14 @@
"setting": "Configuración", "setting": "Configuración",
"copy": "Copiar", "copy": "Copiar",
"delete": "Borrar", "delete": "Borrar",
"execute": "Ejecutar",
"sign-in": "Iniciar sesión", "sign-in": "Iniciar sesión",
"sign-out": "Desconectar", "sign-out": "Desconectar",
"back": "Devolver", "back": "Devolver",
"quota": "Cuota", "quota": "Cuota Mensual",
"execute": "Ejecutar" "date": "Fecha",
"description": "Descripción",
"amount": "Cantidad"
}, },
"conversation": { "conversation": {
"new-chat": "Nuevo Chat", "new-chat": "Nuevo Chat",
@ -56,13 +59,19 @@
"self": "Configuración", "self": "Configuración",
"general": "General", "general": "General",
"plan": { "plan": {
"guest": "Invitada",
"free": "Gratis", "free": "Gratis",
"pro": "Pro", "pro": "Pro",
"signup-for-more": "Regístrese para obtener más", "signup-for-more": "Regístrese para obtener más",
"upgrade": "Mejora" "upgrade": "Mejora",
"renew": "Renovar",
"expired": "Expirado",
"n-question-per-month": "{{count}} preguntas / mes",
"early-bird-checkout": "Descuento por reserva anticipada, 50 % de descuento durante 1 año"
}, },
"billing": { "billing": {
"self": "Facturación" "self": "Facturación",
"view-receipt": "Ver recibo"
}, },
"account": { "account": {
"self": "Cuenta" "self": "Cuenta"
@ -92,11 +101,12 @@
"banner": { "banner": {
"data-storage": "Las configuraciones de conexión y las consultas se almacenan en tu navegador.", "data-storage": "Las configuraciones de conexión y las consultas se almacenan en tu navegador.",
"non-select-sql-warning": "La declaración actual puede no ser SELECT SQL, lo que dará como resultado un esquema de base de datos o un cambio de datos. Asegúrate de saber lo que estás haciendo.", "non-select-sql-warning": "La declaración actual puede no ser SELECT SQL, lo que dará como resultado un esquema de base de datos o un cambio de datos. Asegúrate de saber lo que estás haciendo.",
"product-hunt": "🚀🚀🚀 Acabamos de lanzar en Product Hunt, ¡por favor, vota por nosotros! 🚀🚀🚀" "product-hunt": "🚀🚀🚀 Acabamos de lanzar en Product Hunt, ¡por favor, vota por nosotros! 🚀🚀🚀",
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.",
"use-my-key": "Usar mi propia clave"
}, },
"payment": { "payment": {
"self": "Pago", "self": "Pago",
"quota-overflow": "Cuota excedida. Proporcione su propia clave API de OpenAI.", "sign-in-to-buy": "Inicia sesión para comprar"
"use-my-key": "Usar mi propia clave"
} }
} }

View File

@ -13,7 +13,10 @@
"sign-in": "登录", "sign-in": "登录",
"sign-out": "登出", "sign-out": "登出",
"back": "返回", "back": "返回",
"quota": "额度" "quota": "每月额度",
"date": "日期",
"description": "描述",
"amount": "金额"
}, },
"conversation": { "conversation": {
"new-chat": "新建会话", "new-chat": "新建会话",
@ -58,15 +61,19 @@
"self": "设置", "self": "设置",
"general": "通用", "general": "通用",
"plan": { "plan": {
"guest": "访客",
"free": "免费版", "free": "免费版",
"pro": "专业版", "pro": "专业版",
"signup-for-more": "注册提升额度", "signup-for-more": "注册获得额度",
"upgrade": "升级", "upgrade": "升级",
"pro-question-per-month": "1000 次提问 / 月", "renew": "续费",
"expired": "已过期",
"n-question-per-month": "{{count}} 次提问 / 月",
"early-bird-checkout": "早鸟优惠5 折购买 1 年" "early-bird-checkout": "早鸟优惠5 折购买 1 年"
}, },
"billing": { "billing": {
"self": "计费" "self": "计费",
"view-receipt": "查看收据"
}, },
"account": { "account": {
"self": "账户" "self": "账户"
@ -102,6 +109,6 @@
}, },
"payment": { "payment": {
"self": "支付", "self": "支付",
"login-to-buy": "登陆后购买" "sign-in-to-buy": "登陆后购买"
} }
} }

View File

@ -4,6 +4,7 @@ import GoogleProvider from "next-auth/providers/google";
import { PrismaAdapter } from "@next-auth/prisma-adapter"; import { PrismaAdapter } from "@next-auth/prisma-adapter";
import EmailProvider from "next-auth/providers/email"; import EmailProvider from "next-auth/providers/email";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import { getSubscriptionByEmail } from "../utils/subscription";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
@ -27,6 +28,25 @@ export const authOptions: NextAuthOptions = {
clientSecret: process.env.GITHUB_SECRET, clientSecret: process.env.GITHUB_SECRET,
}), }),
], ],
cookies: {
sessionToken: {
name: `next-auth.session-token`,
options: {
httpOnly: true,
sameSite: "lax",
path: "/",
secure: true,
},
},
},
callbacks: {
async session({ session, user }) {
session.user.id = user.id;
session.user.subscription = await getSubscriptionByEmail(user.email);
session.user.stripeId = user.stripeId;
return session;
},
},
theme: { theme: {
brandColor: "#4F46E5", brandColor: "#4F46E5",
logo: "/chat-logo.webp", logo: "/chat-logo.webp",

View File

@ -1,16 +1,38 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next"; import { getServerSession } from "next-auth/next";
import { authOptions } from "./[...nextauth]"; import { authOptions } from "./[...nextauth]";
import requestIp from "request-ip"; import requestIp from "request-ip";
const prisma = new PrismaClient();
// Returns the login user email or the client IP address // Returns the login user email or the client IP address
export const getEndUser = async ( export const getEndUser = async (
req: NextApiRequest, req: NextApiRequest,
res: NextApiResponse res: NextApiResponse
): Promise<string> => { ): Promise<string> => {
const session = await getServerSession(req, res, authOptions); // Get from server session if available
if (session?.user?.email) { const serverSession = await getServerSession(req, res, authOptions);
return session.user.email; if (serverSession?.user?.email) {
return serverSession.user.email;
} }
// Get from session token if available
const token = req.headers.authorization?.substring(7);
if (token) {
const sessionInDb = await prisma.session.findUnique({
where: { sessionToken: token },
});
if (sessionInDb?.userId) {
const user = await prisma.user.findUnique({
where: { id: sessionInDb.userId },
});
if (user?.email) {
return user.email;
}
}
}
// Get from client IP address
return requestIp.getClientIp(req) || ""; return requestIp.getClientIp(req) || "";
}; };

View File

@ -1,33 +0,0 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { getServerSession } from "next-auth/next";
import { authOptions } from "./[...nextauth]";
import { getEndUser } from "./end-user";
import { Quota, GUEST_QUOTA, FREE_QUOTA } from "@/types";
const prisma = new PrismaClient();
export const checkQuota = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<boolean> => {
const quota = await getQuota(req, res);
return quota.current <= quota.limit;
};
export const getQuota = async (
req: NextApiRequest,
res: NextApiResponse
): Promise<Quota> => {
const endUser = await getEndUser(req, res);
const session = await getServerSession(req, res, authOptions);
return {
current: await prisma.message.count({
where: {
endUser: endUser,
role: "user",
},
}),
limit: session ? FREE_QUOTA : GUEST_QUOTA,
};
};

View File

@ -4,9 +4,9 @@ import {
ReconnectInterval, ReconnectInterval,
} from "eventsource-parser"; } from "eventsource-parser";
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { API_KEY } from "@/env"; import { openAIApiEndpoint, openAIApiKey, gpt35, hasFeature } from "@/utils";
import { openAIApiEndpoint, openAIApiKey, gpt35 } from "@/utils";
// Needs Edge for streaming response.
export const config = { export const config = {
runtime: "edge", runtime: "edge",
}; };
@ -18,28 +18,8 @@ const getApiEndpoint = (apiEndpoint: string) => {
}; };
const handler = async (req: NextRequest) => { const handler = async (req: NextRequest) => {
if (API_KEY) {
const auth = req.headers.get("Authorization");
if (!auth || auth !== `Bearer ${API_KEY}`) {
return new Response(
JSON.stringify({
error: {
message: "Unauthorized.",
},
}),
{
headers: {
"Content-Type": "application/json",
},
status: 401,
}
);
}
}
const reqBody = await req.json(); const reqBody = await req.json();
const openAIApiConfig = reqBody.openAIApiConfig; const apiKey = req.headers.get("x-openai-key") || openAIApiKey;
const apiKey = openAIApiConfig?.key || openAIApiKey;
if (!apiKey) { if (!apiKey) {
return new Response( return new Response(
@ -58,10 +38,69 @@ const handler = async (req: NextRequest) => {
); );
} }
const apiEndpoint = getApiEndpoint( const useServerKey = !req.headers.get("x-openai-key");
openAIApiConfig?.endpoint || openAIApiEndpoint const sessionToken = req.cookies.get("next-auth.session-token")?.value;
const currentUrl = new URL(req.url);
const usageUrl = new URL(
currentUrl.protocol + "//" + currentUrl.host + "/api/usage"
); );
const res = await fetch(apiEndpoint, { const requestHeaders: any = {
Authorization: `Bearer ${sessionToken}`,
};
if (useServerKey) {
if (hasFeature("account") && !sessionToken) {
return new Response(
JSON.stringify({
error: {
message:
"Please sign up to get free quota or supply your own OpenAI key.",
},
}),
{
headers: {
"Content-Type": "application/json",
},
status: 401,
}
);
}
if (hasFeature("quota")) {
const usageRes = await fetch(usageUrl, {
method: "GET",
headers: requestHeaders,
});
if (!usageRes.ok) {
return new Response(usageRes.body, {
status: 500,
statusText: usageRes.statusText,
});
}
const usage = await usageRes.json();
if (usage.current >= usage.limit) {
return new Response(
JSON.stringify({
error: {
message: `You have reached your monthly quota: ${usage.current}/${usage.limit}.`,
},
}),
{
headers: {
"Content-Type": "application/json",
},
status: 401,
}
);
}
}
}
const apiEndpoint = getApiEndpoint(
req.headers.get("x-openai-endpoint") || openAIApiEndpoint
);
const remoteRes = await fetch(apiEndpoint, {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`, Authorization: `Bearer ${apiKey}`,
@ -78,10 +117,10 @@ const handler = async (req: NextRequest) => {
user: req.ip, user: req.ip,
}), }),
}); });
if (!res.ok) { if (!remoteRes.ok) {
return new Response(res.body, { return new Response(remoteRes.body, {
status: res.status, status: remoteRes.status,
statusText: res.statusText, statusText: remoteRes.statusText,
}); });
} }
@ -107,11 +146,20 @@ const handler = async (req: NextRequest) => {
} }
}; };
const parser = createParser(streamParser); const parser = createParser(streamParser);
for await (const chunk of res.body as any) { for await (const chunk of remoteRes.body as any) {
parser.feed(decoder.decode(chunk)); parser.feed(decoder.decode(chunk));
} }
}, },
}); });
if (useServerKey) {
// Increment usage count
await fetch(usageUrl, {
method: "POST",
headers: requestHeaders,
});
}
return new Response(stream); return new Response(stream);
}; };

View File

@ -3,6 +3,7 @@ import { getServerSession } from "next-auth/next";
import { authOptions } from "../auth/[...nextauth]"; import { authOptions } from "../auth/[...nextauth]";
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
import Stripe from "stripe"; import Stripe from "stripe";
import { PlanType } from "@/types";
const stripe = new Stripe(process.env.STRIPE_API_KEY, { const stripe = new Stripe(process.env.STRIPE_API_KEY, {
// https://github.com/stripe/stripe-node#configuration // https://github.com/stripe/stripe-node#configuration
@ -44,7 +45,7 @@ export default async function handler(
], ],
line_items: [ line_items: [
{ {
price: process.env.STRIPE_ANNUAL_LICENSE_PRICE_ID, price: process.env.STRIPE_PRICE_ID_PRO_1_YEAR_SUBSCRIPTION,
quantity: 1, quantity: 1,
}, },
], ],
@ -58,6 +59,8 @@ export default async function handler(
// setup_future_usage: "off_session", // setup_future_usage: "off_session",
metadata: { metadata: {
email: session?.user?.email!, email: session?.user?.email!,
plan: "PRO",
description: "Pro 1 Year License (Early Bird)",
}, },
}, },
// Link customer if present otherwise pass email and let Stripe create a new customer. // Link customer if present otherwise pass email and let Stripe create a new customer.

62
src/pages/api/collect.ts Normal file
View File

@ -0,0 +1,62 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next";
import { Conversation, Message } from "@/types";
import { gpt35 } from "@/utils";
import { getEndUser } from "./auth/end-user";
const prisma = new PrismaClient();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json([]);
}
const conversation = req.body.conversation as Conversation;
const messages = req.body.messages as Message[];
try {
const chat = await prisma.chat.findUnique({
where: {
id: conversation.id,
},
});
const endUser = await getEndUser(req, res);
if (chat) {
await prisma.message.createMany({
data: messages.map((message) => ({
chatId: chat.id,
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
});
} else {
await prisma.chat.create({
data: {
id: conversation.id,
createdAt: new Date(conversation.createdAt),
model: gpt35,
ctx: {},
messages: {
create: messages.map((message) => ({
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
},
},
});
}
} catch (err) {
console.error(err);
}
res.status(200).json(true);
}

View File

@ -1,11 +0,0 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getQuota } from "./auth/quota";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json([]);
}
res.status(200).json(await getQuota(req, res));
};
export default handler;

View File

@ -0,0 +1,16 @@
import { NextApiRequest, NextApiResponse } from "next";
import { getEndUser } from "./auth/end-user";
import { getSubscriptionListByEmail } from "./utils/subscription";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET") {
return res.status(405).json([]);
}
const endUser = await getEndUser(req, res);
const subscriptionList = await getSubscriptionListByEmail(endUser);
res.status(200).json(subscriptionList);
};
export default handler;

View File

@ -1,62 +1,38 @@
import { PrismaClient } from "@prisma/client";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { Conversation, Message } from "@/types"; import { getServerSession } from "next-auth/next";
import { gpt35 } from "@/utils"; import { authOptions } from "./auth/[...nextauth]";
import { getSubscriptionByEmail } from "./utils/subscription";
import { addUsage, getCurrentMonthUsage } from "./utils/usage";
import { getEndUser } from "./auth/end-user"; import { getEndUser } from "./auth/end-user";
import { Quota } from "@/types";
const prisma = new PrismaClient(); const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "GET" && req.method !== "POST") {
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json([]); return res.status(405).json([]);
} }
const conversation = req.body.conversation as Conversation;
const messages = req.body.messages as Message[]; const endUser = await getEndUser(req, res);
try {
const chat = await prisma.chat.findUnique({ // Get from server session if available
where: { const serverSession = await getServerSession(req, res, authOptions);
id: conversation.id, let subscripion = serverSession?.user?.subscription;
}, if (!subscripion) {
}); subscripion = await getSubscriptionByEmail(endUser);
const endUser = await getEndUser(req, res);
if (chat) {
await prisma.message.createMany({
data: messages.map((message) => ({
chatId: chat.id,
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
});
} else {
await prisma.chat.create({
data: {
id: conversation.id,
createdAt: new Date(conversation.createdAt),
model: gpt35,
ctx: {},
messages: {
create: messages.map((message) => ({
createdAt: new Date(message.createdAt),
endUser: endUser,
role: message.creatorRole,
content: message.content,
upvote: false,
downvote: false,
})),
},
},
});
}
} catch (err) {
console.error(err);
} }
res.status(200).json(true); let usage = 0;
} if (req.method === "GET") {
usage = await getCurrentMonthUsage(endUser);
} else if (req.method === "POST") {
usage = await addUsage(endUser);
}
const quota: Quota = {
current: usage,
limit: subscripion.quota,
};
res.status(200).json(quota);
};
export default handler;

View File

@ -0,0 +1,85 @@
import {
PlanConfig,
PlanType,
Subscription,
SubscriptionPurchase,
} from "@/types";
import { PrismaClient, SubscriptionStatus } from "@prisma/client";
const prisma = new PrismaClient();
export const getSubscriptionByEmail = async (
email: string
): Promise<Subscription> => {
const subscriptions = await prisma.subscription.findMany({
where: { email: email },
orderBy: { expireAt: "desc" },
});
// Return the latest ACTIVE, not-expired subscription if exists.
for (const subscription of subscriptions) {
const result = {
plan: subscription.plan as PlanType,
quota: PlanConfig[subscription.plan].quota,
status: subscription.status,
startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(),
};
if (
subscription.status === SubscriptionStatus.ACTIVE &&
subscription.expireAt.getTime() > Date.now()
) {
return result;
}
}
// Return the latest ACTIVE, expired subscripion if exists.
for (const subscription of subscriptions) {
const result = {
plan: subscription.plan as PlanType,
quota: PlanConfig["FREE"].quota,
status: subscription.status,
startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(),
};
if (subscription.status === SubscriptionStatus.ACTIVE) {
return result;
}
}
// Return a FREE subscription.
return {
plan: "FREE",
quota: PlanConfig["FREE"].quota,
status: SubscriptionStatus.ACTIVE,
startAt: 0,
expireAt: 0,
};
};
export const getSubscriptionListByEmail = async (
email: string
): Promise<SubscriptionPurchase[]> => {
const subscriptions = await prisma.subscription.findMany({
where: { email: email },
orderBy: { expireAt: "desc" },
});
// Return the latest ACTIVE, not-expired subscription if exists.
const result: SubscriptionPurchase[] = [];
for (const subscription of subscriptions) {
result.push({
id: subscription.id,
email: subscription.email,
amount: subscription.amount,
currency: subscription.currency,
receipt: subscription.receipt,
plan: subscription.plan as PlanType,
description: subscription.description,
createdAt: subscription.createdAt.getTime(),
startAt: subscription.startAt.getTime(),
expireAt: subscription.expireAt.getTime(),
});
}
return result;
};

View File

@ -0,0 +1,63 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export const getCurrentMonthUsage = async (
endUser: string
): Promise<number> => {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), 1);
const end = new Date(now.getFullYear(), now.getMonth() + 1, 1);
const aggregations = await prisma.usage.aggregate({
_sum: {
count: true,
},
where: {
endUser: endUser,
createdAt: {
gte: start,
lt: end,
},
},
});
return aggregations._sum.count || 0;
};
// We coerce individual usage to the begining of the day to reduce the usage records.
export const addUsage = async (endUser: string): Promise<number> => {
const now = new Date();
const today = new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())
);
const usage = await prisma.usage.findFirst({
where: {
endUser: endUser,
createdAt: today,
},
});
let newUsage = 0;
if (usage) {
newUsage = usage.count + 1;
await prisma.usage.update({
where: {
id: usage.id,
},
data: {
count: newUsage,
},
});
} else {
newUsage = 1;
await prisma.usage.create({
data: {
endUser: endUser,
createdAt: today,
count: 1,
},
});
}
return newUsage;
};

View File

@ -1,9 +1,10 @@
import { buffer } from "micro"; import { buffer } from "micro";
import Cors from "micro-cors"; import Cors from "micro-cors";
import { NextApiRequest, NextApiResponse } from "next"; import { NextApiRequest, NextApiResponse } from "next";
import { PrismaClient, Prisma } from "@prisma/client"; import { PrismaClient, Prisma, SubscriptionPlan } from "@prisma/client";
import Stripe from "stripe"; import Stripe from "stripe";
import { PlanType } from "@/types";
const stripe = new Stripe(process.env.STRIPE_API_KEY, { const stripe = new Stripe(process.env.STRIPE_API_KEY, {
// https://github.com/stripe/stripe-node#configuration // https://github.com/stripe/stripe-node#configuration
apiVersion: "2022-11-15", apiVersion: "2022-11-15",
@ -66,14 +67,26 @@ const webhookHandler = async (req: NextApiRequest, res: NextApiResponse) => {
}); });
const today = new Date(new Date().setHours(0, 0, 0, 0)); const today = new Date(new Date().setHours(0, 0, 0, 0));
// Subtract 1 second from the year from now to make it 23:59:59
const yearFromNow = new Date(
new Date(new Date().setHours(0, 0, 0, 0)).setFullYear(
today.getFullYear() + 1
) - 1000
);
const subscription: Prisma.SubscriptionUncheckedCreateInput = { const subscription: Prisma.SubscriptionUncheckedCreateInput = {
userId: user.id, userId: user.id,
email: paymentIntent.metadata.email,
createdAt: new Date(paymentIntent.created * 1000), createdAt: new Date(paymentIntent.created * 1000),
status: "ACTIVE", status: "ACTIVE",
startAt: today, startAt: today,
expireAt: new Date(today.setFullYear(today.getFullYear() + 1)), expireAt: yearFromNow,
paymentId: paymentIntent.id, paymentId: paymentIntent.id,
customerId: customerId || "", customerId: customerId || "",
plan: paymentIntent.metadata.plan as SubscriptionPlan,
description: paymentIntent.metadata.description,
amount: paymentIntent.amount,
currency: paymentIntent.currency,
receipt: paymentIntent.charges.data[0].receipt_url,
}; };
await prisma.subscription.create({ data: subscription }); await prisma.subscription.create({ data: subscription });
} else if (event.type === "payment_intent.payment_failed") { } else if (event.type === "payment_intent.payment_failed") {

View File

@ -4,7 +4,13 @@ import { Fragment, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import Link from "next/link"; import Link from "next/link";
import { Dialog, Transition } from "@headlessui/react"; import { Dialog, Transition } from "@headlessui/react";
import { ArrowUturnLeftIcon, Bars3Icon, Cog6ToothIcon, XMarkIcon, CreditCardIcon } from "@heroicons/react/24/outline"; import {
ArrowUturnLeftIcon,
Bars3Icon,
Cog6ToothIcon,
XMarkIcon,
CreditCardIcon,
} from "@heroicons/react/24/outline";
import SettingView from "../../components/SettingView"; import SettingView from "../../components/SettingView";
import StripeCheckPaymentBanner from "../../components/StripeCheckPaymentBanner"; import StripeCheckPaymentBanner from "../../components/StripeCheckPaymentBanner";
@ -45,7 +51,11 @@ const SettingPage: NextPage = () => {
<div className="dark:bg-zinc-800"> <div className="dark:bg-zinc-800">
<Transition.Root show={sidebarOpen} as={Fragment}> <Transition.Root show={sidebarOpen} as={Fragment}>
<Dialog as="div" className="relative z-50 lg:hidden" onClose={setSidebarOpen}> <Dialog
as="div"
className="relative z-50 lg:hidden"
onClose={setSidebarOpen}
>
<Transition.Child <Transition.Child
as={Fragment} as={Fragment}
enter="transition-opacity ease-linear duration-300" enter="transition-opacity ease-linear duration-300"
@ -79,9 +89,16 @@ const SettingPage: NextPage = () => {
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="absolute left-full top-0 flex w-16 justify-center pt-5"> <div className="absolute left-full top-0 flex w-16 justify-center pt-5">
<button type="button" className="-m-2.5 p-2.5" onClick={() => setSidebarOpen(false)}> <button
type="button"
className="-m-2.5 p-2.5"
onClick={() => setSidebarOpen(false)}
>
<span className="sr-only">Close sidebar</span> <span className="sr-only">Close sidebar</span>
<XMarkIcon className="h-6 w-6 text-white" aria-hidden="true" /> <XMarkIcon
className="h-6 w-6 text-white"
aria-hidden="true"
/>
</button> </button>
</div> </div>
</Transition.Child> </Transition.Child>
@ -99,13 +116,17 @@ const SettingPage: NextPage = () => {
<Link <Link
href={item.href} href={item.href}
className={classNames( className={classNames(
item.current ? "bg-gray-50 text-indigo-600" : "text-gray-700 hover:text-indigo-600 hover:bg-gray-50", item.current
? "bg-gray-50 text-indigo-600"
: "text-gray-700 hover:text-indigo-600 hover:bg-gray-50",
"group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold" "group flex gap-x-3 rounded-md p-2 text-sm leading-6 font-semibold"
)} )}
> >
<item.icon <item.icon
className={classNames( className={classNames(
item.current ? "text-indigo-600" : "text-gray-400 group-hover:text-indigo-600", item.current
? "text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0" "h-6 w-6 shrink-0"
)} )}
aria-hidden="true" aria-hidden="true"
@ -149,7 +170,9 @@ const SettingPage: NextPage = () => {
> >
<item.icon <item.icon
className={classNames( className={classNames(
item.current ? "!text-indigo-600" : "text-gray-400 group-hover:text-indigo-600", item.current
? "!text-indigo-600"
: "text-gray-400 group-hover:text-indigo-600",
"h-6 w-6 shrink-0 dark:text-gray-400" "h-6 w-6 shrink-0 dark:text-gray-400"
)} )}
aria-hidden="true" aria-hidden="true"
@ -166,14 +189,22 @@ const SettingPage: NextPage = () => {
</div> </div>
<div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white dark:bg-zinc-700 px-4 py-4 shadow-sm sm:px-6 lg:hidden"> <div className="sticky top-0 z-40 flex items-center gap-x-6 bg-white dark:bg-zinc-700 px-4 py-4 shadow-sm sm:px-6 lg:hidden">
<button type="button" className="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400 lg:hidden" onClick={() => setSidebarOpen(true)}> <button
type="button"
className="-m-2.5 p-2.5 text-gray-700 dark:text-gray-400 lg:hidden"
onClick={() => setSidebarOpen(true)}
>
<span className="sr-only">Open sidebar</span> <span className="sr-only">Open sidebar</span>
<Bars3Icon className="h-6 w-6" aria-hidden="true" /> <Bars3Icon className="h-6 w-6" aria-hidden="true" />
</button> </button>
</div> </div>
<main className="lg:pl-72"> <main className="lg:pl-72">
{router.query.session_id && <StripeCheckPaymentBanner sessionId={router.query.session_id as string} />} {router.query.session_id && (
<StripeCheckPaymentBanner
sessionId={router.query.session_id as string}
/>
)}
<div className="px-4 sm:px-6 lg:px-8"> <div className="px-4 sm:px-6 lg:px-8">
<SettingView /> <SettingView />
</div> </div>

View File

@ -6,3 +6,4 @@ export * from "./message";
export * from "./layout"; export * from "./layout";
export * from "./query"; export * from "./query";
export * from "./setting"; export * from "./setting";
export * from "../utils/event-emitter";

View File

@ -9,3 +9,4 @@ export * from "./api";
export * from "./connector"; export * from "./connector";
export * from "./assistant"; export * from "./assistant";
export * from "./quota"; export * from "./quota";
export * from "./subscription";

View File

@ -2,8 +2,3 @@ export interface Quota {
current: number; current: number;
limit: number; limit: number;
} }
// By month
export const GUEST_QUOTA = 10;
export const FREE_QUOTA = 25;
export const PRO_QUOTA = 1000;

41
src/types/subscription.ts Normal file
View File

@ -0,0 +1,41 @@
import { SubscriptionPlan, SubscriptionStatus } from "@prisma/client";
export type PlanType = SubscriptionPlan | "GUEST" | "FREE";
// Quota is by month
export const PlanConfig: {
[key: string]: {
quota: number;
};
} = {
GUEST: {
quota: 0,
},
FREE: {
quota: 20,
},
PRO: {
quota: 1000,
},
};
export interface Subscription {
plan: PlanType;
quota: number;
status: SubscriptionStatus;
startAt: number;
expireAt: number;
}
export interface SubscriptionPurchase {
id: string;
email: string;
amount: number;
currency: string;
receipt: string;
plan: PlanType;
description: string;
createdAt: number;
startAt: number;
expireAt: number;
}

View File

@ -0,0 +1,37 @@
export type EventType = "usage.update";
type Callback = () => void;
class EventEmitter {
private static instance: EventEmitter;
private events: { [eventName: string]: Callback[] } = {};
private constructor() {}
public static getInstance(): EventEmitter {
if (!EventEmitter.instance) {
EventEmitter.instance = new EventEmitter();
}
return EventEmitter.instance;
}
on(eventName: EventType, callback: Callback) {
if (!this.events[eventName]) {
this.events[eventName] = [];
}
this.events[eventName].push(callback);
}
emit(eventName: EventType) {
const callbacks = this.events[eventName];
if (callbacks) {
callbacks.forEach((callback) => callback());
}
}
}
const getEventEmitter = () => {
return EventEmitter.getInstance();
};
export default getEventEmitter;

View File

@ -1,8 +1,20 @@
type FeatureType = "account" | "payment" | "quota"; type FeatureType = "debug" | "account" | "payment" | "quota";
export const HasFeature = (feature: FeatureType) => { const matrix: { [key: string]: { [feature: string]: boolean } } = {
if (process.env.NODE_ENV === "development") { development: {
return true; debug: true,
} account: true,
return false; payment: true,
quota: true,
},
production: {
debug: true,
account: true,
payment: true,
quota: true,
},
};
export const hasFeature = (feature: FeatureType) => {
return matrix[process.env.NODE_ENV][feature];
}; };

View File

@ -4,4 +4,5 @@ export * from "./sql";
export * from "./execution"; export * from "./execution";
export * from "./model"; export * from "./model";
export * from "./feature"; export * from "./feature";
export * from "./tidb"; export * from "./tidb";
export * from "./misc";

24
src/utils/misc.ts Normal file
View File

@ -0,0 +1,24 @@
import { timeStamp } from "console";
export const getCurrencySymbol = (currencyCode: string): string => {
try {
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: currencyCode,
})
.formatToParts(1)
.find((part) => part.type === "currency").value;
} catch (error) {
console.error(`Invalid currency code: ${currencyCode}`);
return "";
}
};
export const getDateString = (timeStamp = Date.now()): string => {
const dateOptions: Intl.DateTimeFormatOptions = {
year: "numeric",
month: "long",
day: "numeric",
};
return new Date(timeStamp).toLocaleDateString(undefined, dateOptions);
};

View File

@ -18,6 +18,12 @@
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["next-env.d.ts", "process.d.ts", "**/*.ts", "**/*.tsx"], "include": [
"next-env.d.ts",
"process.d.ts",
"auth.d.ts",
"**/*.ts",
"**/*.tsx"
],
"exclude": ["node_modules"] "exclude": ["node_modules"]
} }