= ({
14 | id,
15 | selected,
16 | items,
17 | variant = 'toggle',
18 | size = 'base',
19 | onChange,
20 | }) => {
21 | const [pos, setPos] = useState({ left: 0, width: 0 });
22 | const [hasInteractedOnce, setHasInteractedOnce] = useState(false);
23 | const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
24 |
25 | useEffect(() => {
26 | function setTabPosition() {
27 | const currentTab = buttonRefs.current[selected];
28 | setPos({
29 | left: currentTab?.offsetLeft ?? 0,
30 | width: currentTab?.clientWidth ?? 0,
31 | });
32 | }
33 |
34 | setTabPosition();
35 |
36 | window.addEventListener('resize', setTabPosition);
37 |
38 | return () => window.removeEventListener('resize', setTabPosition);
39 | }, [selected]);
40 |
41 | return (
42 |
48 | {items.map((item, i) => {
49 | return (
50 |
(buttonRefs.current[i] = el)}
52 | className={cn(
53 | 'z-10 appearance-none font-medium outline-none transition duration-500 focus:outline-none',
54 | {
55 | 'text-white': selected === i,
56 | 'text-neutral-500 hover:text-white': selected !== i,
57 | 'text-sm': size === 'base',
58 | 'text-xs': size === 'sm' || size === 'xs',
59 | 'px-4 py-1': size === 'base' && variant === 'toggle',
60 | 'px-3 py-0.5': size === 'sm' && variant === 'toggle',
61 | 'px-1 py-0.5': size === 'xs' && variant === 'toggle',
62 | 'rounded-full ': variant === 'toggle',
63 | 'border-b border-transparent': variant === 'text',
64 | 'subtle-underline': selected === i && variant === 'text',
65 | },
66 | )}
67 | onClick={(e) => {
68 | setHasInteractedOnce(true);
69 | onChange(i);
70 | }}
71 | key={`segment-${id}-${i}`}
72 | >
73 | {item}
74 |
75 | );
76 | })}
77 | {variant === 'toggle' && (
78 |
90 | )}
91 |
92 | );
93 | };
94 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import '@/styles/globals.css';
2 |
3 | import type { AppProps } from 'next/app';
4 | import { createBrowserSupabaseClient } from '@supabase/auth-helpers-nextjs';
5 | import {
6 | SessionContextProvider,
7 | useSession,
8 | } from '@supabase/auth-helpers-react';
9 | import { ThemeProvider } from 'next-themes';
10 | import { ReactNode, useEffect, useState } from 'react';
11 | import { NextComponentType, NextPageContext } from 'next';
12 | import { Toaster } from '@/components/ui/Toaster';
13 | import { ManagedAppContext } from '@/lib/context/app';
14 | import { ManagedTrainingContext } from '@/lib/context/training';
15 | import * as Fathom from 'fathom-client';
16 | import { useRouter } from 'next/router';
17 | import { PlainProvider } from '@team-plain/react-chat-ui';
18 | import { ChatWindow, plainTheme } from '@/components/user/ChatWindow';
19 | import { getHost } from '@/lib/utils';
20 |
21 | interface CustomAppProps extends AppProps
{
22 | Component: NextComponentType & {
23 | getLayout?: (page: ReactNode) => JSX.Element;
24 | title?: string;
25 | };
26 | }
27 |
28 | const getCustomerJwt = async () => {
29 | return fetch('/api/user/jwt')
30 | .then((res) => res.json())
31 | .then((res) => res.customerJwt);
32 | };
33 |
34 | export default function App({ Component, pageProps }: CustomAppProps) {
35 | const router = useRouter();
36 | const [supabase] = useState(() => createBrowserSupabaseClient());
37 |
38 | useEffect(() => {
39 | const origin = getHost();
40 | if (!process.env.NEXT_PUBLIC_FATHOM_SITE_ID || !origin) {
41 | return;
42 | }
43 |
44 | Fathom.load(process.env.NEXT_PUBLIC_FATHOM_SITE_ID, {
45 | includedDomains: [origin],
46 | });
47 |
48 | function onRouteChangeComplete() {
49 | Fathom.trackPageview();
50 | }
51 | router.events.on('routeChangeComplete', onRouteChangeComplete);
52 |
53 | return () => {
54 | router.events.off('routeChangeComplete', onRouteChangeComplete);
55 | };
56 | // eslint-disable-next-line react-hooks/exhaustive-deps
57 | }, []);
58 |
59 | return (
60 | <>
61 |
62 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | >
77 | );
78 | }
79 |
80 | export const ManagedPlainProvider = ({ children }: { children: ReactNode }) => {
81 | const session = useSession();
82 |
83 | return (
84 |
93 | {children}
94 |
95 |
96 | );
97 | };
98 |
--------------------------------------------------------------------------------
/lib/github.ts:
--------------------------------------------------------------------------------
1 | import { Octokit } from 'octokit';
2 | import { isPresent } from 'ts-is-present';
3 |
4 | const octokit = new Octokit();
5 |
6 | const parseGitHubURL = (url: string) => {
7 | const match = url.match(
8 | /^https:\/\/github.com\/([a-zA-Z0-9\-_\.]+)\/([a-zA-Z0-9\-_\.]+)/,
9 | );
10 | if (match && match.length > 2) {
11 | return { owner: match[1], repo: match[2] };
12 | }
13 | return undefined;
14 | };
15 |
16 | export const isGitHubRepoAccessible = async (url: string) => {
17 | const info = parseGitHubURL(url);
18 | if (!info?.owner && !info?.repo) {
19 | return false;
20 | }
21 | try {
22 | const res = await octokit.request(`GET /repos/${info.owner}/${info.repo}`);
23 | if (res.status === 200) {
24 | return true;
25 | }
26 | } catch (e) {
27 | //
28 | }
29 | return false;
30 | };
31 |
32 | const getRepo = async (owner: string, repo: string) => {
33 | const res = await octokit.request('GET /repos/{owner}/{repo}', {
34 | owner,
35 | repo,
36 | });
37 | return res.data;
38 | };
39 |
40 | const getDefaultBranch = async (owner: string, repo: string) => {
41 | const _repo = await getRepo(owner, repo);
42 |
43 | const branchRes = await octokit.request(
44 | `GET /repos/{owner}/{repo}/branches/{branch}`,
45 | { owner, repo, branch: _repo.default_branch },
46 | );
47 |
48 | return branchRes.data;
49 | };
50 |
51 | const getTree = async (owner: string, repo: string) => {
52 | const defaultBranch = await getDefaultBranch(owner, repo);
53 |
54 | const tree = await octokit.request(
55 | 'GET /repos/{owner}/{repo}/git/trees/{tree_sha}',
56 | {
57 | owner,
58 | repo,
59 | tree_sha: defaultBranch.commit.sha,
60 | recursive: '1',
61 | },
62 | );
63 |
64 | return tree.data.tree;
65 | };
66 |
67 | export const getOwnerRepoString = (url: string) => {
68 | const info = parseGitHubURL(url);
69 | if (!info?.owner && !info?.repo) {
70 | return undefined;
71 | }
72 | return `${info.owner}/${info.repo}`;
73 | };
74 |
75 | export const getRepositoryMDFilesInfo = async (
76 | url: string,
77 | ): Promise<{ name: string; path: string; url: string; sha: string }[]> => {
78 | const info = parseGitHubURL(url);
79 | if (!info?.owner && !info?.repo) {
80 | return [];
81 | }
82 |
83 | const tree = await getTree(info.owner, info.repo);
84 |
85 | const mdFileUrls = tree
86 | .map((f) => {
87 | if (f.url && f.path && /\.md(x|oc)?$/.test(f.path)) {
88 | let path = f.path;
89 | if (path.startsWith('.')) {
90 | // Ignore files in dot folders, like .github
91 | return undefined;
92 | }
93 | if (!path.startsWith('/')) {
94 | path = '/' + path;
95 | }
96 | return {
97 | name: f.path.split('/').slice(-1)[0],
98 | path,
99 | url: f.url,
100 | sha: f.sha || '',
101 | };
102 | }
103 | return undefined;
104 | })
105 | .filter(isPresent);
106 | return mdFileUrls;
107 | };
108 |
109 | export const getContent = async (url: string): Promise => {
110 | const res = await fetch(url, {
111 | headers: { accept: 'application/json' },
112 | }).then((res) => res.json());
113 | return Buffer.from(res.content, 'base64').toString('utf8');
114 | };
115 |
--------------------------------------------------------------------------------
/lib/redis.ts:
--------------------------------------------------------------------------------
1 | import { Project } from '@/types/types';
2 | import { Redis } from '@upstash/redis';
3 |
4 | let redis: Redis | undefined = undefined;
5 |
6 | const monthBin = (date: Date) => {
7 | return `${date.getFullYear()}/${date.getMonth() + 1}`;
8 | };
9 |
10 | export const getProjectChecksumsKey = (projectId: Project['id']) => {
11 | return `${process.env.NODE_ENV}:project:${projectId}:checksums`;
12 | };
13 |
14 | export const getProjectEmbeddingsMonthTokenCountKey = (
15 | projectId: Project['id'],
16 | date: Date,
17 | ) => {
18 | return `${process.env.NODE_ENV}:project:${projectId}:token_count:${monthBin(
19 | date,
20 | )}`;
21 | };
22 |
23 | export const getRedisClient = () => {
24 | if (!redis) {
25 | redis = new Redis({
26 | url: process.env.UPSTASH_URL || '',
27 | token: process.env.UPSTASH_TOKEN || '',
28 | });
29 | }
30 | return redis;
31 | };
32 |
33 | export const safeGetObject = async (
34 | key: string,
35 | defaultValue: T,
36 | ): Promise => {
37 | const value = await get(key);
38 | if (value) {
39 | try {
40 | return JSON.parse(value);
41 | } catch (e) {
42 | // Do nothing
43 | }
44 | }
45 | return defaultValue;
46 | };
47 |
48 | export const get = async (key: string): Promise => {
49 | try {
50 | return getRedisClient().get(key);
51 | } catch (e) {
52 | console.error('Redis `get` error', e);
53 | }
54 | return null;
55 | };
56 |
57 | export const set = async (key: string, value: string) => {
58 | try {
59 | await getRedisClient().set(key, value);
60 | } catch (e) {
61 | console.error('Redis `set` error', e, key, value);
62 | }
63 | };
64 |
65 | export const setWithExpiration = async (
66 | key: string,
67 | value: string,
68 | expirationInSeconds: number,
69 | ) => {
70 | try {
71 | await getRedisClient().set(key, value, { ex: expirationInSeconds });
72 | } catch (e) {
73 | console.error('Redis `set` error', e);
74 | }
75 | };
76 |
77 | export const hget = async (key: string, field: string): Promise => {
78 | try {
79 | return getRedisClient().hget(key, field);
80 | } catch (e) {
81 | console.error('Redis `hget` error', e);
82 | }
83 | return undefined;
84 | };
85 |
86 | export const hset = async (key: string, object: any) => {
87 | try {
88 | await getRedisClient().hset(key, object);
89 | } catch (e) {
90 | console.error('Redis `hset` error', e);
91 | }
92 | };
93 |
94 | export const del = async (key: string) => {
95 | try {
96 | await getRedisClient().del(key);
97 | } catch (e) {
98 | console.error('Redis `del` error', e);
99 | }
100 | };
101 |
102 | export const batchGet = async (keys: string[]) => {
103 | try {
104 | const pipeline = getRedisClient().pipeline();
105 | for (const key of keys) {
106 | pipeline.get(key);
107 | }
108 | return pipeline.exec();
109 | } catch (e) {
110 | console.error('Redis `batchGet` error', e);
111 | }
112 | };
113 |
114 | export const batchDel = async (keys: string[]) => {
115 | try {
116 | const pipeline = getRedisClient().pipeline();
117 | for (const key of keys) {
118 | pipeline.del(key);
119 | }
120 | await pipeline.exec();
121 | } catch (e) {
122 | console.error('Redis `batchDel` error', e);
123 | }
124 | };
125 |
--------------------------------------------------------------------------------
/components/user/ChatWindow.tsx:
--------------------------------------------------------------------------------
1 | import { FC, useState } from 'react';
2 | import { Chat } from '@team-plain/react-chat-ui';
3 | import { ChatBubbleIcon, Cross2Icon } from '@radix-ui/react-icons';
4 | import * as Popover from '@radix-ui/react-popover';
5 | import cn from 'classnames';
6 | import colors from 'tailwindcss/colors';
7 | import { useSession } from '@supabase/auth-helpers-react';
8 |
9 | type ChatWindowProps = {};
10 |
11 | export const plainTheme = {
12 | input: {
13 | borderColor: colors.neutral['200'],
14 | borderColorFocused: colors.neutral['500'],
15 | borderColorError: colors.rose['500'],
16 | borderColorDisabled: colors.neutral['100'],
17 | focusBoxShadow: '',
18 | textColorPlaceholder: colors.neutral['400'],
19 | },
20 | buttonPrimary: {
21 | background: colors.neutral['900'],
22 | backgroundHover: colors.neutral['800'],
23 | backgroundDisabled: colors.neutral['200'],
24 | textColor: colors.white,
25 | textColorDisabled: colors.neutral['400'],
26 | borderRadius: '6px',
27 | },
28 | composer: {
29 | iconButtonColor: colors.neutral['900'],
30 | iconButtonColorHover: colors.neutral['500'],
31 | },
32 | textColor: {
33 | base: colors.neutral['900'],
34 | muted: colors.neutral['500'],
35 | error: colors.rose['500'],
36 | },
37 | };
38 |
39 | export const ChatWindow: FC = () => {
40 | const [chatOpen, setChatOpen] = useState(false);
41 | const session = useSession();
42 |
43 | return (
44 |
45 |
46 |
47 |
51 |
52 |
60 |
68 |
69 |
70 |
71 |
72 |
73 | e.preventDefault()}
77 | >
78 |
87 |
88 |
89 |
90 | );
91 | };
92 |
--------------------------------------------------------------------------------
/components/onboarding/Onboarding.tsx:
--------------------------------------------------------------------------------
1 | import Head from 'next/head';
2 | import { NavLayout } from '@/components/layouts/NavLayout';
3 | import { useCallback, useState } from 'react';
4 | import cn from 'classnames';
5 | import AddFiles from './AddFiles';
6 | import Query from './Query';
7 | import Button from '../ui/Button';
8 | import { toast } from 'react-hot-toast';
9 | import useUser from '@/lib/hooks/use-user';
10 | import { updateUser } from '@/lib/api';
11 | import { showConfetti } from '@/lib/utils';
12 | import Router from 'next/router';
13 | import useTeam from '@/lib/hooks/use-team';
14 | import useProject from '@/lib/hooks/use-project';
15 |
16 | const Onboarding = () => {
17 | const { team } = useTeam();
18 | const { project } = useProject();
19 | const { user, mutate: mutateUser } = useUser();
20 | const [step, setStep] = useState(0);
21 | const [ctaVisible, setCtaVisible] = useState(false);
22 |
23 | if (!user) {
24 | return <>>;
25 | }
26 |
27 | return (
28 | <>
29 |
30 | Get started | Markprompt
31 |
32 |
33 |
34 |
39 |
{
41 | toast.success('Processing complete');
42 | setTimeout(() => {
43 | setStep(1);
44 | }, 1000);
45 | }}
46 | onNext={() => {
47 | setStep(1);
48 | }}
49 | />
50 |
51 |
59 |
{
61 | setStep(0);
62 | }}
63 | didCompleteFirstQuery={async () => {
64 | setTimeout(() => {
65 | showConfetti();
66 | }, 1000);
67 | setTimeout(() => {
68 | setCtaVisible(true);
69 | }, 2000);
70 | }}
71 | isReady={step === 1}
72 | />
73 |
74 | {
81 | const data = { has_completed_onboarding: true };
82 | await updateUser(data);
83 | await mutateUser();
84 | if (team && project) {
85 | Router.push({
86 | pathname: '/[team]/[project]/data',
87 | query: { team: team.slug, project: project.slug },
88 | });
89 | }
90 | }}
91 | >
92 | Go to dashboard →
93 |
94 |
95 |
96 |
97 |
98 | >
99 | );
100 | };
101 |
102 | export default Onboarding;
103 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "markprompt",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@markdoc/markdoc": "^0.2.2",
13 | "@mdx-js/loader": "^2.3.0",
14 | "@mdx-js/react": "^2.3.0",
15 | "@next/mdx": "^13.2.4",
16 | "@radix-ui/react-checkbox": "^1.0.3",
17 | "@radix-ui/react-dialog": "^1.0.3",
18 | "@radix-ui/react-dropdown-menu": "^2.0.4",
19 | "@radix-ui/react-icons": "^1.2.0",
20 | "@radix-ui/react-navigation-menu": "^1.1.2",
21 | "@radix-ui/react-popover": "^1.0.5",
22 | "@radix-ui/react-slider": "^1.1.1",
23 | "@radix-ui/react-toggle-group": "^1.0.3",
24 | "@sindresorhus/slugify": "^2.2.0",
25 | "@stripe/stripe-js": "^1.49.0",
26 | "@supabase/auth-helpers-nextjs": "^0.5.6",
27 | "@supabase/auth-helpers-react": "^0.3.1",
28 | "@supabase/auth-ui-react": "^0.3.5",
29 | "@supabase/supabase-js": "^2.10.0",
30 | "@tailwindcss/forms": "^0.5.3",
31 | "@tanstack/react-table": "^8.7.9",
32 | "@team-plain/react-chat-ui": "^6.2.3",
33 | "@upstash/ratelimit": "^0.4.0",
34 | "@upstash/redis": "^1.20.1",
35 | "@visx/axis": "^2.12.2",
36 | "@visx/event": "^2.6.0",
37 | "@visx/grid": "^2.12.2",
38 | "@visx/responsive": "^2.10.0",
39 | "@visx/scale": "^2.2.2",
40 | "@visx/tooltip": "^2.10.0",
41 | "axios": "^1.3.4",
42 | "canvas-confetti": "^1.6.0",
43 | "classnames": "^2.3.2",
44 | "clsx": "^1.2.1",
45 | "common-tags": "^1.8.2",
46 | "dayjs": "^1.11.7",
47 | "eslint": "8.35.0",
48 | "eslint-config-next": "13.2.3",
49 | "eventsource-parser": "^0.1.0",
50 | "exponential-backoff": "^3.1.1",
51 | "fathom-client": "^3.5.0",
52 | "formidable": "^2.1.1",
53 | "formik": "^2.2.9",
54 | "gpt3-tokenizer": "^1.1.5",
55 | "gray-matter": "^4.0.3",
56 | "js-md5": "^0.7.3",
57 | "js-yaml": "^4.1.0",
58 | "jsonwebtoken": "^9.0.0",
59 | "lodash-es": "^4.17.21",
60 | "nanoid": "^4.0.1",
61 | "next": "13.2.3",
62 | "next-themes": "^0.2.1",
63 | "octokit": "^2.0.14",
64 | "openai": "^3.2.1",
65 | "pako": "^2.1.0",
66 | "prism-react-renderer": "^1.3.5",
67 | "react": "18.2.0",
68 | "react-dom": "18.2.0",
69 | "react-dropzone": "^14.2.3",
70 | "react-hot-toast": "^2.4.0",
71 | "react-markdown": "^8.0.5",
72 | "react-wrap-balancer": "^0.4.0",
73 | "remark-gfm": "^3.0.1",
74 | "stripe": "^11.15.0",
75 | "swr": "^2.1.0",
76 | "ts-is-present": "^1.2.2",
77 | "turndown": "^7.1.1",
78 | "unique-names-generator": "^4.7.1",
79 | "unist-builder": "^3.0.1",
80 | "unist-util-filter": "^4.0.1",
81 | "unzipper": "^0.10.11",
82 | "use-debounce": "^9.0.3"
83 | },
84 | "devDependencies": {
85 | "@tailwindcss/typography": "^0.5.9",
86 | "@types/canvas-confetti": "^1.6.0",
87 | "@types/common-tags": "^1.8.1",
88 | "@types/formidable": "^2.0.5",
89 | "@types/js-md5": "^0.7.0",
90 | "@types/js-yaml": "^4.0.5",
91 | "@types/lodash-es": "^4.17.7",
92 | "@types/ms": "0.7.31",
93 | "@types/node": "18.14.6",
94 | "@types/react": "18.0.28",
95 | "@types/react-dom": "18.0.11",
96 | "@types/turndown": "^5.0.1",
97 | "@types/unzipper": "^0.10.5",
98 | "@vercel/git-hooks": "1.0.0",
99 | "autoprefixer": "10.4.13",
100 | "lint-staged": "13.1.0",
101 | "postcss": "8.4.20",
102 | "prettier": "2.8.1",
103 | "prettier-plugin-tailwindcss": "0.2.1",
104 | "tailwindcss": "3.2.4",
105 | "typescript": "4.9.4"
106 | }
107 | }
108 |
--------------------------------------------------------------------------------
/components/ui/Button.tsx:
--------------------------------------------------------------------------------
1 | import cn from 'classnames';
2 | import Link from 'next/link';
3 | import { forwardRef, JSXElementConstructor, ReactNode, useRef } from 'react';
4 | import LoadingDots from './LoadingDots';
5 |
6 | export type ButtonVariant =
7 | | 'cta'
8 | | 'glow'
9 | | 'danger'
10 | | 'ghost'
11 | | 'plain'
12 | | 'fuchsia';
13 |
14 | type ButtonProps = {
15 | buttonSize?: 'sm' | 'base' | 'md' | 'lg';
16 | variant?: ButtonVariant;
17 | href?: string;
18 | Icon?: JSXElementConstructor | string;
19 | children?: ReactNode;
20 | target?: string;
21 | rel?: string;
22 | className?: string;
23 | asLink?: boolean;
24 | disabled?: boolean;
25 | loading?: boolean;
26 | loadingMessage?: string;
27 | Component?: JSXElementConstructor | string;
28 | } & React.HTMLProps;
29 |
30 | const Button = forwardRef(
31 | (
32 | {
33 | buttonSize,
34 | variant,
35 | href,
36 | children,
37 | Icon,
38 | className,
39 | asLink,
40 | disabled,
41 | loading,
42 | loadingMessage,
43 | Component = 'button',
44 | ...props
45 | },
46 | ref,
47 | ) => {
48 | const Comp: any = asLink ? Link : href ? 'a' : Component;
49 |
50 | let size = buttonSize ?? 'base';
51 | return (
52 |
78 | {Icon && }
79 |
85 | {loadingMessage}
86 |
87 | {loading && !loadingMessage && (
88 |
89 |
95 |
96 | )}
97 |
103 | {children}
104 |
105 |
106 | );
107 | },
108 | );
109 |
110 | Button.displayName = 'Button';
111 |
112 | export default Button;
113 |
--------------------------------------------------------------------------------
/lib/middleware/completions.ts:
--------------------------------------------------------------------------------
1 | import { createMiddlewareSupabaseClient } from '@supabase/auth-helpers-nextjs';
2 | import { NextRequest, NextResponse } from 'next/server';
3 | import { checkCompletionsRateLimits } from '../rate-limits';
4 | import { getAuthorizationToken, getHost, removeSchema } from '../utils';
5 | import {
6 | getProjectIdFromToken,
7 | noProjectForTokenResponse,
8 | noTokenResponse,
9 | } from './common';
10 |
11 | export default async function CompletionsMiddleware(req: NextRequest) {
12 | if (process.env.NODE_ENV === 'production') {
13 | if (!req.ip) {
14 | return new Response('Forbidden', { status: 403 });
15 | }
16 |
17 | // Apply rate limiting here already based on IP. After that, apply rate
18 | // limiting on requester origin and token.
19 |
20 | const rateLimitIPResult = await checkCompletionsRateLimits({
21 | value: req.ip,
22 | type: 'ip',
23 | });
24 |
25 | if (!rateLimitIPResult.result.success) {
26 | console.error(`[TRAIN] [RATE-LIMIT] IP ${req.ip}`);
27 | return new Response('Too many requests', { status: 429 });
28 | }
29 | }
30 |
31 | const path = req.nextUrl.pathname;
32 | const requesterOrigin = req.headers.get('origin');
33 |
34 | let projectId;
35 | if (requesterOrigin) {
36 | const requesterHost = removeSchema(requesterOrigin);
37 |
38 | const rateLimitHostnameResult = await checkCompletionsRateLimits({
39 | value: requesterHost,
40 | type: 'hostname',
41 | });
42 |
43 | if (!rateLimitHostnameResult.result.success) {
44 | console.error(
45 | `[TRAIN] [RATE-LIMIT] Origin ${requesterHost}, IP: ${req.ip}`,
46 | );
47 | return new Response('Too many requests', { status: 429 });
48 | }
49 |
50 | if (requesterHost === getHost()) {
51 | // Requests from the Markprompt dashboard explicitly specify
52 | // the project id in the path: /completions/[project]
53 | projectId = path.split('/').slice(-1)[0];
54 | } else {
55 | const res = NextResponse.next();
56 | const projectKey = req.nextUrl.searchParams.get('projectKey');
57 | const supabase = createMiddlewareSupabaseClient({ req, res });
58 |
59 | let { data } = await supabase
60 | .from('projects')
61 | .select('id')
62 | .match({ public_api_key: projectKey })
63 | .limit(1)
64 | .select()
65 | .maybeSingle();
66 |
67 | if (!data?.id) {
68 | return new Response('Project not found', { status: 404 });
69 | }
70 |
71 | projectId = data?.id;
72 |
73 | // Now that we have a project id, we need to check that the
74 | // the project has whitelisted the domain the request comes from.
75 |
76 | let { count } = await supabase
77 | .from('domains')
78 | .select('id', { count: 'exact' })
79 | .eq('project_id', projectId);
80 |
81 | if (count === 0) {
82 | return new Response(
83 | 'This domain is not allowed to access completions for this project',
84 | { status: 401 },
85 | );
86 | }
87 | }
88 | } else {
89 | // Non-browser requests expect an authorization token.
90 | const token = getAuthorizationToken(req.headers.get('Authorization'));
91 | if (!token) {
92 | return noTokenResponse;
93 | }
94 |
95 | // Apply rate-limit here already, before looking up the project id,
96 | // which requires a database lookup.
97 | const rateLimitResult = await checkCompletionsRateLimits({
98 | value: token,
99 | type: 'token',
100 | });
101 |
102 | if (!rateLimitResult.result.success) {
103 | console.error(`[TRAIN] [RATE-LIMIT] Token ${token}, IP: ${req.ip}`);
104 | return new Response('Too many requests', { status: 429 });
105 | }
106 |
107 | const res = NextResponse.next();
108 | const projectId = await getProjectIdFromToken(req, res, token);
109 |
110 | if (!projectId) {
111 | return noProjectForTokenResponse;
112 | }
113 | }
114 |
115 | return NextResponse.rewrite(
116 | new URL(`/api/openai/completions/${projectId}`, req.url),
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/packages/markprompt-react/README.md:
--------------------------------------------------------------------------------
1 | # Markprompt React
2 |
3 | A headless React component for building a prompt interface, based on the [Markprompt](https://markprompt.com) API.
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 | ## Installation
16 |
17 | In [Motif](https://motif.land), paste the following import statement in an MDX, JSX or TSX file:
18 |
19 | ```jsx
20 | import { Markprompt } from 'https://esm.sh/markprompt';
21 | ```
22 |
23 | If you have a Node-based setup, install the `markprompt` package via npm or yarn:
24 |
25 | ```sh
26 | # npm
27 | npm install markprompt
28 |
29 | # Yarn
30 | yarn add markprompt
31 | ```
32 |
33 | ## Usage
34 |
35 | Example:
36 |
37 | ```jsx
38 | import { Markprompt } from 'markprompt';
39 |
40 | function MyPrompt() {
41 | return ;
42 | }
43 | ```
44 |
45 | where `project-key` can be obtained in your project settings, and `model` is the identifier of the OpenAI model to use for completions. Supported models are:
46 |
47 | - Chat completions: `gpt-4` `gpt-4-0314` `gpt-4-32k` `gpt-4-32k-0314` `gpt-3.5-turbo` `gpt-3.5-turbo-0301`
48 | - Completions: `text-davinci-003`, `text-davinci-002`, `text-curie-001`, `text-babbage-001`, `text-ada-001`, `davinci`, `curie`, `babbage`, `ada`
49 |
50 | If no model is specified, `gpt-3.5-turbo` will be used.
51 |
52 | ## Styling
53 |
54 | The Markprompt component is styled using [Tailwind CSS](https://tailwindcss.com/), and therefore requires a working Tailwind configuration. We are planning to make it headless, for more flexible options.
55 |
56 | ## Configuration
57 |
58 | You can pass the following props to the component:
59 |
60 | | Prop | Default value | Description |
61 | | ------------------ | ---------------------------------------- | --------------------------------------------------------------- |
62 | | `iDontKnowMessage` | Sorry, I am not sure how to answer that. | Fallback message in can no answer is found. |
63 | | `placeholder` | 'Ask me anything...' | Message to show in the input box when no text has been entered. |
64 |
65 | Example:
66 |
67 | ```jsx
68 |
74 | ```
75 |
76 | ## Whitelisting your domain
77 |
78 | Usage of the [Markprompt API](https://markprompt.com) is subject to quotas, depending on the plan you have subscribed to. Markprompt has systems in place to detect abuse and excessive usage, but we nevertheless recommend being cautious when offering a prompt interface on a public website. In any case, the prompt will **only work on domains you have whitelisted** through the [Markprompt dashboard](https://markprompt.com).
79 |
80 | ## Passing an authorization token
81 |
82 | If you cannot use a whitelisted domain, for instance when developing on localhost, you can alternatively pass an authorization token:
83 |
84 | ```jsx
85 |
86 | ```
87 |
88 | You can obtain this token in the project settings. This token is tied to a specific project, so adding the `project` prop will not have any effect.
89 |
90 | **Important:** Make sure to keep this token private, and never publish code that exposes it. If your token has been compromised, you can generate a new one in the settings.
91 |
92 | ## Community
93 |
94 | - [Twitter @markprompt](https://twitter.com/markprompt)
95 | - [Twitter @motifland](https://twitter.com/motifland)
96 | - [Discord](https://discord.gg/MBMh4apz6X)
97 |
98 | ## Authors
99 |
100 | This library is created by the team behind [Motif](https://motif.land)
101 | ([@motifland](https://twitter.com/motifland)).
102 |
103 | ## License
104 |
105 | MIT
106 |
--------------------------------------------------------------------------------
/components/examples/analytics.tsx:
--------------------------------------------------------------------------------
1 | import BarChart from '@/components/charts/bar-chart';
2 | import { sampleVisitsData } from '@/lib/utils';
3 | import { useEffect, useState } from 'react';
4 |
5 | export const AnalyticsExample = () => {
6 | const [isMounted, setIsMounted] = useState(false);
7 |
8 | useEffect(() => {
9 | // Prevent SSR/hydration errors.
10 | setIsMounted(true);
11 | }, []);
12 |
13 | if (!isMounted) {
14 | return <>>;
15 | }
16 |
17 | return (
18 | <>
19 |
20 |
Daily queries
21 |
28 |
29 |
30 |
31 |
Most asked
32 |
33 |
34 |
223
35 |
What is an RUV?
36 |
37 |
38 |
185
39 |
When do I need to submit by 83b?
40 |
41 |
42 |
159
43 |
What are preferred rights?
44 |
45 |
46 |
152
47 |
What is the difference between a SAFE and an RUV?
48 |
49 |
50 |
152
51 |
What is the difference between a SAFE and an RUV?
52 |
53 |
54 |
55 |
56 |
57 | Most visited resources
58 |
59 |
60 |
61 |
99
62 |
Roll-ups
63 |
64 |
65 |
87
66 |
Manage your Raise
67 |
68 |
69 |
152
70 |
Cap Tables
71 |
72 |
73 |
151
74 |
409A Valuations
75 |
76 |
77 |
133
78 |
Data Rooms
79 |
80 |
81 |
82 |
83 |
84 |
Reported prompts
85 |
86 |
87 |
9
88 |
How do I send an update?
89 |
90 |
91 |
8
92 |
What is a SAFE?
93 |
94 |
95 |
5
96 |
Where can I see my cap table?
97 |
98 |
99 |
100 |
101 | >
102 | );
103 | };
104 |
--------------------------------------------------------------------------------
/pages/settings/[team]/projects/new.tsx:
--------------------------------------------------------------------------------
1 | import { TeamSettingsLayout } from '@/components/layouts/TeamSettingsLayout';
2 | import Button from '@/components/ui/Button';
3 | import { ErrorLabel } from '@/components/ui/Forms';
4 | import { NoAutoInput } from '@/components/ui/Input';
5 | import { createProject } from '@/lib/api';
6 | import { isGitHubRepoAccessible } from '@/lib/github';
7 | import useProjects from '@/lib/hooks/use-projects';
8 | import useTeam from '@/lib/hooks/use-team';
9 | import { showConfetti } from '@/lib/utils';
10 | import {
11 | ErrorMessage,
12 | Field,
13 | Form,
14 | Formik,
15 | FormikErrors,
16 | FormikValues,
17 | } from 'formik';
18 | import { useRouter } from 'next/router';
19 | import { toast } from 'react-hot-toast';
20 |
21 | const NewProject = () => {
22 | const router = useRouter();
23 | const { team } = useTeam();
24 | const { projects, mutate: mutateProjects } = useProjects();
25 |
26 | return (
27 |
28 |
29 |
30 |
{
34 | let errors: FormikErrors = {};
35 | if (!values.name) {
36 | errors.name = 'Required';
37 | return errors;
38 | }
39 |
40 | if (values.github) {
41 | if (values.github) {
42 | const isAccessible = await isGitHubRepoAccessible(
43 | values.github,
44 | );
45 | if (!isAccessible) {
46 | errors.github = 'Repository is not accessible';
47 | }
48 | }
49 | return errors;
50 | }
51 | return errors;
52 | }}
53 | onSubmit={async (values, { setSubmitting }) => {
54 | if (!team) {
55 | return;
56 | }
57 | const newProject = await createProject(
58 | team.id,
59 | values.name,
60 | values.slug,
61 | values.github,
62 | );
63 | await mutateProjects([...(projects || []), newProject]);
64 | setSubmitting(false);
65 | toast.success('Project created.');
66 | setTimeout(() => {
67 | showConfetti();
68 | router.replace({
69 | pathname: '/[team]/[project]/data',
70 | query: { team: team.slug, project: newProject.slug },
71 | });
72 | }, 500);
73 | }}
74 | >
75 | {({ isSubmitting, isValid }) => (
76 |
110 | )}
111 |
112 |
113 |
114 |
115 | );
116 | };
117 |
118 | export default NewProject;
119 |
--------------------------------------------------------------------------------
/components/onboarding/AddFiles.tsx:
--------------------------------------------------------------------------------
1 | import { FileDnd } from '@/components/files/FileDnd';
2 | import { Code } from '@/components/ui/Code';
3 | import { ClipboardIcon } from '@radix-ui/react-icons';
4 | import { copyToClipboard, getHost, getOrigin, pluralize } from '@/lib/utils';
5 | import { toast } from 'react-hot-toast';
6 | import { FC, ReactNode } from 'react';
7 | import cn from 'classnames';
8 | import useFiles from '@/lib/hooks/use-files';
9 | import useTokens from '@/lib/hooks/use-tokens';
10 | import { GitHub } from '../files/GitHub';
11 |
12 | type TagProps = {
13 | children: ReactNode;
14 | className?: string;
15 | variant?: 'fuchsia' | 'sky';
16 | };
17 |
18 | const Tag: FC = ({ className, variant, children }) => {
19 | return (
20 |
30 | {children}
31 |
32 | );
33 | };
34 |
35 | type AddFilesProps = {
36 | onTrainingComplete: () => void;
37 | onNext: () => void;
38 | };
39 |
40 | const AddFiles: FC = ({ onTrainingComplete, onNext }) => {
41 | const { files } = useFiles();
42 | const { tokens } = useTokens();
43 |
44 | const curlCode = `
45 | curl -d @docs.zip \\
46 | https://api.${getHost()}/generate-embeddings \\
47 | -H "Authorization: Bearer ${tokens?.[0]?.value || ''}"
48 | `.trim();
49 |
50 | return (
51 |
52 |
53 |
Step 1: Import files
54 |
55 | Accepted: .md
56 |
57 | .mdoc
58 |
59 |
60 | .mdx
61 |
62 |
63 | .txt
64 |
65 |
66 |
67 |
68 |
69 |
70 |
or
71 |
72 |
73 |
74 |
or
75 |
76 |
{
79 | copyToClipboard(curlCode);
80 | toast.success('Copied!');
81 | }}
82 | >
83 |
84 |
85 |
86 |
91 |
92 |
93 |
0,
99 | },
100 | )}
101 | onClick={() => onNext()}
102 | >
103 | {pluralize(files?.length || 0, 'file', 'files')} ready. Go to
104 | playground →
105 |
106 |
107 |
108 |
109 | );
110 | };
111 |
112 | export default AddFiles;
113 |
--------------------------------------------------------------------------------
/components/user/ProfileMenu.tsx:
--------------------------------------------------------------------------------
1 | import { useSession } from '@supabase/auth-helpers-react';
2 | import { FC } from 'react';
3 | import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
4 | import Image from 'next/image';
5 | import useUser from '@/lib/hooks/use-user';
6 | import useTeams from '@/lib/hooks/use-teams';
7 | import Link from 'next/link';
8 |
9 | type ProfileMenuProps = {};
10 |
11 | const ProfileMenu: FC = () => {
12 | const session = useSession();
13 | const { user, signOut } = useUser();
14 | const { teams } = useTeams();
15 |
16 | const personalTeam = teams?.find((t) => t.is_personal);
17 |
18 | return (
19 |
20 |
21 |
25 | {session?.user?.user_metadata?.avatar_url ? (
26 |
33 | ) : (
34 |
35 | )}
36 |
37 |
38 |
39 |
43 | {user && (
44 |
45 |
46 |
{user.full_name}
47 |
{user.email}
48 |
49 |
50 | )}
51 |
52 | {personalTeam && (
53 |
54 |
58 | Settings
59 |
60 |
61 | )}
62 |
63 |
64 |
70 | Twitter
71 |
72 |
73 |
74 |
80 | Discord
81 |
82 |
83 |
84 |
90 | GitHub
91 |
92 |
93 |
94 |
100 | Email
101 |
102 |
103 |
104 | signOut()}
106 | className="dropdown-menu-item dropdown-menu-item-noindent"
107 | >
108 | Sign out
109 |
110 |
111 |
112 |
113 | );
114 | };
115 |
116 | export default ProfileMenu;
117 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 | Markprompt
4 |
5 |
6 | Markprompt is a platform for building GPT-powered prompts. It scans Markdown, Markdoc and MDX files in your GitHub repo and creates embeddings that you can use to create a prompt, for instance using the companion [Markprompt React](https://github.com/motifland/markprompt/blob/main/packages/markprompt-react/README.md) component. Markprompt also offers analytics, so you can gain insights on how visitors interact with your docs.
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | ## Self-hosting
23 |
24 | Markprompt is built on top of the following stack:
25 |
26 | - [Next.js](https://nextjs.org/) - framework
27 | - [Vercel](https://vercel.com/) - hosting
28 | - [Typescript](https://www.typescriptlang.org/) - language
29 | - [Tailwind](https://tailwindcss.com/) - CSS
30 | - [Upstash](https://upstash.com/) - Redis and rate limiting
31 | - [Supabase](https://planetscale.com/) - database and auth
32 | - [Stripe](https://stripe.com/) - payments
33 | - [Plain](https://plain.com/) - support chat
34 | - [Fathom](https://plain.com/) - analytics
35 |
36 | ### Supabase
37 |
38 | Supabase is used for storage and auth, and if self-hosting, you will need to set it up on the [Supabase admin console](https://app.supabase.com/).
39 |
40 | #### Schema
41 |
42 | The schema is defined in [schema.sql](https://github.com/motifland/markprompt/blob/main/config/schema.sql). Create a Supabase database and paste the content of this file into the SQL editor. Then run the Typescript types generation script using:
43 |
44 | ```sh
45 | npx supabase gen types typescript --project-id --schema public > types/supabase.ts
46 | ```
47 |
48 | where `` is the id of your Supabase project.
49 |
50 | #### Auth provider
51 |
52 | Authentication is handled by Supabase Auth. Follow the [Login with GitHub](https://supabase.com/docs/guides/auth/social-login/auth-github) and [Login with Google](https://supabase.com/docs/guides/auth/social-login/auth-google) guides to set it up.
53 |
54 | ### Setting environment variables
55 |
56 | A sample file containing required environment variables can be found in [example.env](https://github.com/motifland/markprompt/blob/main/example.env). In addition to the keys for the above services, you will need keys for [Upstash](https://upstash.com/) (rate limiting and key-value storage), [Plain.com](https://plain.com) (support chat), and [Fathom](https://usefathom.com/) (analytics).
57 |
58 | ## Using the React component
59 |
60 | Markprompt React is a headless React component for building a prompt interface, based on the Markprompt API. With a single line of code, you can provide a prompt interface to your React application. Follow the steps in the [Markprompt React README](https://github.com/motifland/markprompt/blob/main/packages/markprompt-react/README.md) to get started using it.
61 |
62 | ## Usage
63 |
64 | Currently, the Markprompt API has basic protection against misuse when making requests from public websites, such as rate limiting, IP blacklisting, allowed origins, and prompt moderation. These are not strong guarantees against misuse though, and it is always safer to expose an API like Markprompt's to authenticated users, and/or in non-public systems using private access tokens. We do plan to offer more extensive tooling on that front (hard limits, spike protection, notifications, query analysis, flagging).
65 |
66 | ## Community
67 |
68 | - [Twitter @markprompt](https://twitter.com/markprompt)
69 | - [Twitter @motifland](https://twitter.com/motifland)
70 | - [Discord](https://discord.gg/MBMh4apz6X)
71 |
72 | ## Authors
73 |
74 | Created by the team behind [Motif](https://motif.land)
75 | ([@motifland](https://twitter.com/motifland)).
76 |
77 | ## License
78 |
79 | MIT
80 |
--------------------------------------------------------------------------------
/components/icons/AngelList.tsx:
--------------------------------------------------------------------------------
1 | import { FC } from 'react'
2 |
3 | type AngeListIconProps = {
4 | className?: string
5 | }
6 |
7 | export const AngeListIcon: FC = ({ className }) => {
8 | return (
9 |
10 |
11 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 | )
23 | }
24 |
--------------------------------------------------------------------------------
/lib/context/app.tsx:
--------------------------------------------------------------------------------
1 | import { useSession } from '@supabase/auth-helpers-react';
2 | import {
3 | createContext,
4 | FC,
5 | PropsWithChildren,
6 | useContext,
7 | useEffect,
8 | } from 'react';
9 | import Router, { useRouter } from 'next/router';
10 | import useUser from '../hooks/use-user';
11 | import useTeam from '../hooks/use-team';
12 | import useTeams from '../hooks/use-teams';
13 | import useProjects from '../hooks/use-projects';
14 | import { initUserData } from '../api';
15 |
16 | export type State = {
17 | isOnboarding: boolean;
18 | };
19 |
20 | const initialContextState: State = {
21 | isOnboarding: false,
22 | };
23 |
24 | const publicRoutes = ['/login', '/signin', '/legal'];
25 |
26 | const AppContextProvider = (props: PropsWithChildren) => {
27 | const router = useRouter();
28 | const session = useSession();
29 | const { user, loading: loadingUser } = useUser();
30 | const { teams, loading: loadingTeams, mutate: mutateTeams } = useTeams();
31 | const { team } = useTeam();
32 | const { projects, mutate: mutateProjects } = useProjects();
33 |
34 | // Create personal team if it doesn't exist
35 | useEffect(() => {
36 | if (user?.has_completed_onboarding) {
37 | return;
38 | }
39 |
40 | if (!user || loadingTeams) {
41 | return;
42 | }
43 |
44 | (async () => {
45 | const team = teams?.find((t) => t.is_personal);
46 | if (!team) {
47 | await initUserData();
48 | await mutateTeams();
49 | await mutateProjects();
50 | }
51 | })();
52 | }, [
53 | teams,
54 | team,
55 | loadingTeams,
56 | session?.user,
57 | user,
58 | mutateTeams,
59 | mutateProjects,
60 | ]);
61 |
62 | useEffect(() => {
63 | if (!user || user.has_completed_onboarding) {
64 | return;
65 | }
66 |
67 | if (!teams) {
68 | return;
69 | }
70 |
71 | const personalTeam = teams.find((t) => t.is_personal);
72 | if (!personalTeam) {
73 | return;
74 | }
75 |
76 | // If user is onboarding and user is not on the personal
77 | // team path, redirect to it.
78 | if (router.query.team !== personalTeam.slug) {
79 | router.push({
80 | pathname: '/[team]',
81 | query: { team: personalTeam.slug },
82 | });
83 | return;
84 | }
85 |
86 | let project = projects?.find((t) => t.is_starter);
87 | if (!project) {
88 | // If no starter project is found, find the first one in the list
89 | // (e.g. if the starter project was deleted).
90 | project = projects?.[0];
91 | if (!project) {
92 | return;
93 | }
94 | }
95 |
96 | // If user is onboarding and user is not on the starter
97 | // project path, redirect to it.
98 | if (
99 | router.query.project !== project.slug ||
100 | router.pathname !== '/[team]/[project]'
101 | ) {
102 | router.push({
103 | pathname: '/[team]/[project]',
104 | query: { team: personalTeam.slug, project: project.slug },
105 | });
106 | }
107 | }, [router, user, teams, projects]);
108 |
109 | useEffect(() => {
110 | if (!user || !user.has_completed_onboarding) {
111 | return;
112 | }
113 |
114 | if (router.pathname !== '/') {
115 | return;
116 | }
117 |
118 | const storedSlug = localStorage.getItem('stored_team_slug');
119 | const slug = teams?.find((t) => t.slug === storedSlug) || teams?.[0];
120 | if (slug) {
121 | Router.push(`/${slug.slug}`);
122 | }
123 | }, [
124 | user,
125 | loadingUser,
126 | user?.has_completed_onboarding,
127 | teams,
128 | router.pathname,
129 | router.asPath,
130 | ]);
131 |
132 | useEffect(() => {
133 | if (router.query.team) {
134 | localStorage.setItem('stored_team_slug', `${router.query.team}`);
135 | }
136 | }, [router.query.team]);
137 |
138 | useEffect(() => {
139 | if (router.query.project) {
140 | localStorage.setItem('stored_project_slug', `${router.query.project}`);
141 | }
142 | }, [router.query.project]);
143 |
144 | return (
145 |
151 | );
152 | };
153 |
154 | export const useAppContext = (): State => {
155 | const context = useContext(AppContext);
156 | if (context === undefined) {
157 | throw new Error(`useAppContext must be used within a AppContextProvider`);
158 | }
159 | return context;
160 | };
161 |
162 | export const AppContext = createContext(initialContextState);
163 |
164 | AppContext.displayName = 'AppContext';
165 |
166 | export const ManagedAppContext: FC = ({ children }) => (
167 | {children}
168 | );
169 |
--------------------------------------------------------------------------------
/pages/api/user/init.ts:
--------------------------------------------------------------------------------
1 | import type { NextApiRequest, NextApiResponse } from 'next';
2 | import { createServerSupabaseClient } from '@supabase/auth-helpers-nextjs';
3 | import { Database } from '@/types/supabase';
4 | import slugify from '@sindresorhus/slugify';
5 | import { generateKey, generateRandomSlug, slugFromEmail } from '@/lib/utils';
6 | import { getAvailableTeamSlug } from '../slug/generate-team-slug';
7 | import { Project, Team } from '@/types/types';
8 |
9 | type Data =
10 | | {
11 | status?: string;
12 | error?: string;
13 | }
14 | | { team: Team; project: Project };
15 |
16 | const allowedMethods = ['POST'];
17 |
18 | export default async function handler(
19 | req: NextApiRequest,
20 | res: NextApiResponse,
21 | ) {
22 | if (!req.method || !allowedMethods.includes(req.method)) {
23 | res.setHeader('Allow', allowedMethods);
24 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
25 | }
26 |
27 | const supabase = createServerSupabaseClient({ req, res });
28 | const {
29 | data: { session },
30 | } = await supabase.auth.getSession();
31 |
32 | if (!session?.user) {
33 | return res.status(403).json({ error: 'Forbidden' });
34 | }
35 |
36 | // Check if personal team already exists
37 | let { data: team } = await supabase
38 | .from('teams')
39 | .select('id')
40 | .match({ created_by: session.user.id, is_personal: true })
41 | .limit(1)
42 | .select()
43 | .maybeSingle();
44 |
45 | if (!team) {
46 | let candidateSlug = '';
47 | if (session.user.user_metadata?.user_name) {
48 | candidateSlug = slugify(session.user.user_metadata?.user_name);
49 | } else if (session.user.user_metadata?.name) {
50 | candidateSlug = slugify(session.user.user_metadata?.name);
51 | } else if (session.user.email) {
52 | candidateSlug = slugFromEmail(session.user.email);
53 | } else {
54 | candidateSlug = generateRandomSlug();
55 | }
56 |
57 | const slug = await getAvailableTeamSlug(supabase, candidateSlug);
58 | let { data, error } = await supabase
59 | .from('teams')
60 | .insert([
61 | {
62 | name: 'Personal',
63 | is_personal: true,
64 | slug,
65 | created_by: session.user.id,
66 | },
67 | ])
68 | .select('*')
69 | .limit(1)
70 | .maybeSingle();
71 | if (error) {
72 | return res.status(400).json({ error: error.message });
73 | }
74 |
75 | team = data;
76 | }
77 |
78 | if (!team) {
79 | return res.status(400).json({ error: 'Unable to create team' });
80 | }
81 |
82 | // Check if membership already exists
83 | const { count: membershipCount } = await supabase
84 | .from('memberships')
85 | .select('id', { count: 'exact' })
86 | .match({ user_id: session.user.id, team_id: team.id, type: 'admin' });
87 |
88 | if (membershipCount === 0) {
89 | // Automatically add the creator of the team as an admin member.
90 | const { error: membershipError } = await supabase
91 | .from('memberships')
92 | .insert([{ user_id: session.user.id, team_id: team.id, type: 'admin' }]);
93 |
94 | if (membershipError) {
95 | return res.status(400).json({ error: membershipError.message });
96 | }
97 | }
98 |
99 | // Check if starter project already exists
100 | let { data: project } = await supabase
101 | .from('projects')
102 | .select('id')
103 | .match({ team_id: team.id, is_starter: true })
104 | .limit(1)
105 | .select()
106 | .maybeSingle();
107 |
108 | if (!project) {
109 | // Create a starter project
110 | const public_api_key = generateKey();
111 | const { data, error: projectError } = await supabase
112 | .from('projects')
113 | .insert([
114 | {
115 | name: 'Starter',
116 | slug: 'starter',
117 | is_starter: true,
118 | created_by: session.user.id,
119 | team_id: team.id,
120 | public_api_key,
121 | },
122 | ])
123 | .select('*')
124 | .limit(1)
125 | .maybeSingle();
126 |
127 | if (projectError) {
128 | return res.status(400).json({ error: projectError.message });
129 | }
130 |
131 | project = data;
132 | }
133 |
134 | if (!project) {
135 | return res.status(400).json({ error: 'Unable to create starter project' });
136 | }
137 |
138 | // Check if token already exists
139 | let { count: tokenCount } = await supabase
140 | .from('tokens')
141 | .select('id', { count: 'exact' })
142 | .match({ project_id: project.id });
143 |
144 | if (tokenCount === 0) {
145 | const value = generateKey();
146 | await supabase.from('tokens').insert([
147 | {
148 | value,
149 | project_id: project.id,
150 | created_by: session.user.id,
151 | },
152 | ]);
153 | }
154 |
155 | return res.status(200).json({ team, project });
156 | }
157 |
--------------------------------------------------------------------------------
/pages/api/subscriptions/webhook.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from 'next';
2 | import Stripe from 'stripe';
3 | import { Readable } from 'node:stream';
4 | import { stripe } from '@/lib/stripe/server';
5 | import { createClient } from '@supabase/supabase-js';
6 | import { Database } from '@/types/supabase';
7 | import { truncateMiddle } from '@/lib/utils';
8 |
9 | export const config = {
10 | api: {
11 | bodyParser: false,
12 | },
13 | };
14 |
15 | const relevantEvents = new Set([
16 | 'checkout.session.completed',
17 | 'customer.subscription.updated',
18 | 'customer.subscription.deleted',
19 | ]);
20 |
21 | const allowedMethods = ['POST'];
22 |
23 | type Data = string | { error: string } | { received: boolean };
24 |
25 | const buffer = async (readable: Readable) => {
26 | const chunks = [];
27 | for await (const chunk of readable) {
28 | chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk);
29 | }
30 | return Buffer.concat(chunks);
31 | };
32 |
33 | // Admin access to Supabase, bypassing RLS.
34 | const supabaseAdmin = createClient(
35 | process.env.NEXT_PUBLIC_SUPABASE_URL || '',
36 | process.env.SUPABASE_SERVICE_ROLE_KEY || '',
37 | );
38 |
39 | export default async function handler(
40 | req: NextApiRequest,
41 | res: NextApiResponse,
42 | ) {
43 | if (!req.method || !allowedMethods.includes(req.method)) {
44 | res.setHeader('Allow', allowedMethods);
45 | return res.status(405).json({ error: `Method ${req.method} Not Allowed` });
46 | }
47 |
48 | const buf = await buffer(req);
49 | const sig = req.headers['stripe-signature'];
50 | const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
51 | let event: Stripe.Event;
52 |
53 | try {
54 | if (!sig || !webhookSecret) {
55 | return;
56 | }
57 |
58 | event = stripe.webhooks.constructEvent(buf, sig, webhookSecret);
59 | } catch (e: any) {
60 | console.error('Subscriptions webhook error:', e.message);
61 | return res.status(400).send(`Error: ${e.message}`);
62 | }
63 |
64 | if (relevantEvents.has(event.type)) {
65 | try {
66 | // When adding a new event type here, make sure to add them to the
67 | // Stripe dashboard as well.
68 | switch (event.type) {
69 | case 'checkout.session.completed': {
70 | // When a user subscribes to a plan, attach the associated Stripe
71 | // customer ID to the team for easy subsequent retrieval.
72 | const checkoutSession = event.data.object as Stripe.Checkout.Session;
73 | if (
74 | checkoutSession.metadata?.appId !==
75 | process.env.NEXT_PUBLIC_STRIPE_APP_ID
76 | ) {
77 | }
78 | if (!checkoutSession.customer) {
79 | throw new Error('Invalid customer.');
80 | }
81 | // Stripe knows the team id as it's been associated to
82 | // client_reference_id during checkout.
83 | const teamId = checkoutSession.client_reference_id;
84 |
85 | const { error } = await supabaseAdmin
86 | .from('teams')
87 | .update({
88 | stripe_customer_id: checkoutSession.customer.toString(),
89 | })
90 | .eq('id', teamId);
91 | if (error) {
92 | console.error('Error session completed', error.message);
93 | throw new Error(
94 | `Unable to update customer in database: ${error.message}`,
95 | );
96 | }
97 | break;
98 | }
99 | case 'customer.subscription.updated': {
100 | const subscription = event.data.object as Stripe.Subscription;
101 | const newPriceId = subscription.items.data[0].price.id;
102 | const stripeCustomerId = subscription.customer.toString();
103 | const { error } = await supabaseAdmin
104 | .from('teams')
105 | .update({
106 | stripe_price_id: newPriceId,
107 | billing_cycle_start: new Date().toISOString(),
108 | })
109 | .eq('stripe_customer_id', stripeCustomerId);
110 | if (error) {
111 | throw new Error(`Error updating price: ${error.message}`);
112 | }
113 | break;
114 | }
115 | case 'customer.subscription.deleted': {
116 | const subscription = event.data.object as Stripe.Subscription;
117 | const stripeCustomerId = subscription.customer.toString();
118 | const { error } = await supabaseAdmin
119 | .from('teams')
120 | .update({
121 | stripe_customer_id: null,
122 | stripe_price_id: null,
123 | billing_cycle_start: null,
124 | })
125 | .eq('stripe_customer_id', stripeCustomerId);
126 | if (error) {
127 | throw new Error(`Error deleting subscription: ${error.message}`);
128 | }
129 | break;
130 | }
131 | default:
132 | throw new Error('Unhandled event.');
133 | }
134 | } catch (error) {
135 | console.error('Subscriptions webhook error:', error);
136 | return res
137 | .status(400)
138 | .send('Subscriptions webhook error. Check the logs.');
139 | }
140 | }
141 |
142 | res.json({ received: true });
143 | }
144 |
--------------------------------------------------------------------------------
/components/onboarding/Query.tsx:
--------------------------------------------------------------------------------
1 | import { FC, ReactNode, useState } from 'react';
2 | import cn from 'classnames';
3 | import { Code } from '../ui/Code';
4 | import { ClipboardIcon } from '@radix-ui/react-icons';
5 | import { copyToClipboard, pluralize } from '@/lib/utils';
6 | import { toast } from 'react-hot-toast';
7 | import { Playground } from '../files/Playground';
8 | import useFiles from '@/lib/hooks/use-files';
9 | import useTeam from '@/lib/hooks/use-team';
10 | import useProject from '@/lib/hooks/use-project';
11 |
12 | const npmCode = `
13 | npm install markprompt
14 | `.trim();
15 |
16 | const reactCode = (teamSlug: string, projectSlug: string) =>
17 | `
18 | // Use on whitelisted domain
19 | import { Markprompt } from "markprompt"
20 |
21 |
23 | `.trim();
24 |
25 | type QueryProps = {
26 | goBack: () => void;
27 | didCompleteFirstQuery: () => void;
28 | isReady?: boolean;
29 | };
30 |
31 | const Query: FC = ({ goBack, didCompleteFirstQuery, isReady }) => {
32 | const { team } = useTeam();
33 | const { project } = useProject();
34 | const { files } = useFiles();
35 | const [showCode, setShowCode] = useState(false);
36 |
37 | if (!team || !project) {
38 | return <>>;
39 | }
40 |
41 | return (
42 |
43 |
44 |
Step 2: Query docs
45 |
46 | Trained on {pluralize(files?.length || 0, 'file', 'files')}.{' '}
47 |
51 | Add more files
52 |
53 |
54 |
55 |
60 |
61 |
76 |
77 |
78 |
79 | $
80 |
81 |
82 |
83 |
{
86 | copyToClipboard(npmCode);
87 | toast.success('Copied!');
88 | }}
89 | >
90 |
91 |
92 |
93 |
94 |
95 |
96 |
100 |
101 |
{
104 | copyToClipboard(reactCode(team.slug, project.slug));
105 | toast.success('Copied!');
106 | }}
107 | >
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
setShowCode(false)}
122 | >
123 | Playground
124 |
125 |
setShowCode(true)}
131 | >
132 | Code
133 |
134 |
135 |
136 |
137 |
138 | );
139 | };
140 |
141 | export default Query;
142 |
--------------------------------------------------------------------------------