Skip to content

Commit

Permalink
Merge pull request #671 from tone-row/dev
Browse files Browse the repository at this point in the history
v1.49.0
  • Loading branch information
rob-gordon authored May 1, 2024
2 parents 4d84940 + 7b8c611 commit d2af59d
Show file tree
Hide file tree
Showing 44 changed files with 1,535 additions and 365 deletions.
2 changes: 1 addition & 1 deletion api/_lib/_openai.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OpenAI } from "openai";

const apiKey = process.env.OPENAI_SECRET;
const apiKey = process.env.OPENAI_API_KEY;
if (!apiKey) throw new Error("No OpenAI API key provided");

export const openai = new OpenAI({
Expand Down
4 changes: 0 additions & 4 deletions api/_lib/_validStripePrices.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,11 @@
const priceId = process.env.STRIPE_PRICE_ID;
const priceIdYearly = process.env.STRIPE_PRICE_ID_YEARLY;
const legacyPriceId = process.env.LEGACY_STRIPE_PRICE_ID;
const legacyPriceIdYearly = process.env.LEGACY_STRIPE_PRICE_ID_YEARLY;
const otherValidPriceIdsString = process.env.OTHER_VALID_STRIPE_PRICE_IDS;
const otherValidPriceIds = otherValidPriceIdsString
? otherValidPriceIdsString.split(",")
: [];
export const validStripePrices = [
priceId,
priceIdYearly,
legacyPriceId,
legacyPriceIdYearly,
...otherValidPriceIds,
].filter(Boolean) as string[];
3 changes: 3 additions & 0 deletions api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
"@octokit/core": "^4.2.0",
"@sendgrid/mail": "^7.4.6",
"@supabase/supabase-js": "^2.31.0",
"@upstash/ratelimit": "^1.1.3",
"@vercel/kv": "^1.0.1",
"ai": "^3.1.0",
"ajv": "^8.12.0",
"axios": "^0.27.2",
"csv-parse": "^5.3.6",
Expand Down
123 changes: 123 additions & 0 deletions api/prompt/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { z } from "zod";
import { OpenAIStream, StreamingTextResponse } from "ai";
import OpenAI from "openai";
import { stripe } from "../_lib/_stripe";
import { kv } from "@vercel/kv";
import { Ratelimit } from "@upstash/ratelimit";

export const config = {
runtime: "edge",
};

const reqSchema = z.object({
prompt: z.string().min(1),
});

// Create an OpenAI API client (that's edge friendly!)
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || "",
});

export default async function handler(req: Request) {
const ip = getIp(req);

let isPro = false,
customerId: null | string = null;

// Check for auth token
const token = req.headers.get("Authorization");

if (token) {
// get sid from token
const sid = token.split(" ")[1];

// check if subscription is active or trialing
const sub = await stripe.subscriptions.retrieve(sid);
if (sub.status === "active" || sub.status === "trialing") {
isPro = true;
customerId = sub.customer as string;
}
}

// Implement rate-limiting based on IP for unauthorized users and customerId for authorized users
// Initialize Upstash Ratelimit
const ratelimit = new Ratelimit({
redis: kv,
limiter: isPro
? Ratelimit.slidingWindow(3, "1m") // Pro users: 3 requests per minute
: Ratelimit.fixedWindow(1, "30d"), // Unauthenticated users: 1 request per month
});

// Determine the key for rate limiting
const rateLimitKey = isPro ? `pro_${customerId}` : `unauth_${ip}`;

// Check the rate limit
const { success, limit, reset, remaining } = await ratelimit.limit(
rateLimitKey
);

if (!success) {
return new Response("You have reached your request limit.", {
status: 429,
headers: {
"X-RateLimit-Limit": limit.toString(),
"X-RateLimit-Remaining": remaining.toString(),
"X-RateLimit-Reset": reset.toString(),
},
});
}

const body = await req.json();
const parsed = reqSchema.safeParse(body);

if (!parsed.success) {
return new Response(JSON.stringify(parsed.error), { status: 400 });
}

// Ask OpenAI for a streaming chat completion given the prompt
const response = await openai.chat.completions.create({
model: "gpt-4-turbo",
stream: true,
messages: [
{
role: "system",
content: `You are the Flowchart Fun creation assistant. When I give you a document respond with a diagram in Flowchart Fun syntax. The Flowchart Fun syntax you use indentation to express a tree shaped graph. You use text before a colon to labels to edges. You link back to earlier nodes by referring to their label in parentheses. The following characters must be escaped when used in a node or edge label: (,:,#, and .\n\nHere is a very simple graph illustrating the syntax:
Node A
Node B
\\(Secret Node)
Node C
label from c to d: Node D
label from d to a: (Node A)
Note: Don't provide any explanation. Don't wrap your response in a code block.`,
},
{
role: "user",
content:
`Create a flowchart using flowchart fun syntax based on the following document:\n\n` +
parsed.data.prompt,
},
],
});

// Convert the response into a friendly text-stream
const stream = OpenAIStream(response);
// Respond with the stream
return new StreamingTextResponse(stream);
}

function getIp(req: Request) {
return (
req.headers.get("x-real-ip") ||
req.headers.get("cf-connecting-ip") ||
req.headers.get("x-forwarded-for") ||
req.headers.get("x-client-ip") ||
req.headers.get("x-cluster-client-ip") ||
req.headers.get("forwarded-for") ||
req.headers.get("forwarded") ||
req.headers.get("via") ||
req.headers.get("x-forwarded") ||
req.headers.get
);
}
2 changes: 0 additions & 2 deletions app/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
LEGACY_STRIPE_PRICE_ID_YEARLY=
LEGACY_STRIPE_PRICE_ID=
REACT_APP_SENTRY_ENVIRONMENT=
STRIPE_PRICE_ID_YEARLY=
NOTION_ACCESS_TOKEN=
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "app",
"version": "1.48.5",
"version": "1.49.0",
"main": "module/module.js",
"license": "MIT",
"scripts": {
Expand Down
Binary file added app/public/images/ai-convert.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
66 changes: 66 additions & 0 deletions app/src/components/AiToolbar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import classNames from "classnames";
import { globalZ } from "../lib/globalZ";
import { useDoc } from "../lib/useDoc";
import { useEffect, useMemo } from "react";
import { getDefaultText } from "../lib/getDefaultText";
import { useEditorStore } from "../lib/useEditorStore";
import { ConvertToFlowchart } from "./ConvertToFlowchart";
import { EditWithAI } from "./EditWithAI";
import { usePromptStore } from "../lib/usePromptStore";

/**
* Watch the current state of the graph and the users actions and determine
* which, if any, AI tools to display to the user.
*/
export function AiToolbar() {
const text = useDoc((state) => state.text);
const defaultText = useMemo(() => {
return getDefaultText();
}, []);
const isDefaultText = text === defaultText;
const selection = useEditorStore((s) => s.selection);
const fullTextSelected = selection.trim() === text.trim();
const userPasted = useEditorStore((s) => s.userPasted);
const enoughCharacters = text.length > 150;
const lastResult = usePromptStore((s) => s.lastResult);

// Set the user pasted back to false after 15 seconds, and on unmount
useEffect(() => {
if (userPasted) {
const timeout = setTimeout(() => {
useEditorStore.setState({ userPasted: false });
}, 15000);
return () => clearTimeout(timeout);
}
}, [userPasted]);

const convertIsRunning = usePromptStore((s) => s.convertIsRunning);

// Qualities for displaying Convert to Flowchart button:
// OR
// Convert is currently running
// AND
// Is not the default text
// There is more than 150 characters
// Text is not equal to the last result
// OR
// Full text is selected and is more than 150 characters
// Less than 15 seconds have passed since user pasted more than 150 characters
const showConvertToFlowchart =
convertIsRunning ||
(!isDefaultText &&
enoughCharacters &&
lastResult !== text &&
(fullTextSelected || userPasted));

return (
<div
className={classNames(
"drop-shadow-lg absolute top-2 right-2",
globalZ.editWithAiButton
)}
>
{showConvertToFlowchart ? <ConvertToFlowchart /> : <EditWithAI />}
</div>
);
}
101 changes: 101 additions & 0 deletions app/src/components/ConvertToFlowchart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Trans, t } from "@lingui/macro";
import { Button2 } from "../ui/Shared";
import { TreeStructure } from "phosphor-react";
import { useDoc } from "../lib/useDoc";
import * as Toast from "@radix-ui/react-toast";
import {
RATE_LIMIT_EXCEEDED,
convertToFlowchart,
} from "../lib/convertToFlowchart";
import {
startConvert,
stopConvert,
usePromptStore,
} from "../lib/usePromptStore";
import { useEditorStore } from "../lib/useEditorStore";
import { useCallback, useContext, useState } from "react";
import { AppContext } from "./AppContextProvider";
import { isError } from "../lib/helpers";
import { useIsProUser } from "../lib/hooks";
import { showPaywall } from "../lib/usePaywallModalStore";

export function ConvertToFlowchart() {
const convertIsRunning = usePromptStore((s) => s.convertIsRunning);
const customer = useContext(AppContext).customer;
const sid = customer?.subscription?.id;
const isProUser = useIsProUser();
const [errorMessage, setErrorMessage] = useState<string | null>(null);

// Handle the error based on status
const handleError = useCallback(
(error: Error) => {
if (!isProUser && error.message === RATE_LIMIT_EXCEEDED) {
// Show paywall
showPaywall({
title: t`Transform text into diagrams instantly`,
content: t`Uh oh, you're out of free requests! Upgrade to Flowchart Fun Pro for unlimited diagram conversions, and keep transforming text into clear, visual flowcharts as easily as copy and paste.`,
imgUrl: "/images/ai-convert.png",
toPricingCode: "ConvertToFlowchart",
});
} else {
if (error.message === RATE_LIMIT_EXCEEDED) {
setErrorMessage(t`Rate limit exceeded. Please try again later.`);
} else {
setErrorMessage(error.message);
}
}
},
[isProUser]
);

return (
<>
<Button2
onClick={() => {
const prompt = useDoc.getState().text;
startConvert();
convertToFlowchart(prompt, sid)
.catch((err) => {
if (isError(err)) handleError(err);
})
.finally(() => {
stopConvert();
useEditorStore.setState({ userPasted: false });
});
}}
disabled={convertIsRunning}
leftIcon={
<TreeStructure
className="group-hover-tilt-shaking md:-mr-1 -mr-4"
size={18}
/>
}
color="green"
size="sm"
rounded
className="!pt-2 !pb-[9px] !pl-3 !pr-4 disabled:!opacity-100"
data-session-activity="Convert To Flowchart"
isLoading={convertIsRunning}
>
<span className="text-[15px] hidden md:inline">
<Trans>Convert to Flowchart</Trans>
</span>
</Button2>
<Toast.Root
type="foreground"
duration={4000}
className="ToastRoot bg-red-300 text-red-800 font-bold border-b-2 border-r-2 border-red-500 rounded-md shadow p-4 grid [grid-template-areas:_'title_action'_'description_action'] grid-cols-[auto_max-content] gap-x-4 items-center data-[state=open]:animate-slideIn data-[state=closed]:animate-hide data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=cancel]:translate-x-0 data-[swipe=cancel]:transition-[transform_200ms_ease-out] data-[swipe=end]:animate-swipeOut"
open={errorMessage !== null}
onOpenChange={(open) => {
if (!open) setErrorMessage(null);
}}
>
<Toast.Description>
<div className="flex text-xs items-center gap-3">
<p className="leading-normal">{errorMessage}</p>
</div>
</Toast.Description>
</Toast.Root>
</>
);
}
Loading

0 comments on commit d2af59d

Please sign in to comment.