Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ability to Stop AI Process #750

Merged
merged 1 commit into from
Oct 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 31 additions & 17 deletions app/src/components/AiToolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Button2, IconButton2, Textarea } from "../ui/Shared";
import { CaretDown, CaretUp, MagicWand } from "phosphor-react";
import { CaretDown, CaretUp, MagicWand, Stop } from "phosphor-react";
import cx from "classnames";
import { t, Trans } from "@lingui/macro";
import { createExamples } from "../pages/createExamples";
Expand Down Expand Up @@ -44,7 +44,7 @@ export function AiToolbar() {
const isOpen = usePromptStore((state) => state.isOpen);
const currentMode = usePromptStore((state) => state.mode);
const isRunning = usePromptStore((state) => state.isRunning);
const runAiWithStore = useRunAiWithStore();
const { runAi, cancelAi } = useRunAiWithStore();
const diff = usePromptStore((state) => state.diff);

const toggleOpen = () => setIsOpen(!isOpen);
Expand Down Expand Up @@ -109,19 +109,33 @@ export function AiToolbar() {
)}
</div>
{!showAcceptDiffButton ? (
<IconButton2
onClick={toggleOpen}
color="default"
size="xs"
className="flex items-center justify-center dark:bg-neutral-800 dark:text-neutral-400"
isLoading={isRunning}
>
{!isOpen ? (
<CaretDown size={16} weight="bold" />
) : (
<CaretUp size={16} weight="bold" />
)}
</IconButton2>
<>
<div className="relative">
<IconButton2
onClick={toggleOpen}
color="default"
size="xs"
className="flex items-center justify-center dark:bg-neutral-800 dark:text-neutral-400"
isLoading={isRunning}
>
{!isOpen ? (
<CaretDown size={16} weight="bold" />
) : (
<CaretUp size={16} weight="bold" />
)}
</IconButton2>
{isRunning ? (
<IconButton2
color="red"
size="xs"
className="flex items-center justify-center !absolute top-0 left-0 opacity-0 hover:opacity-100"
onClick={cancelAi}
>
<Stop size={16} weight="bold" />
</IconButton2>
) : null}
</div>
</>
) : (
<div className="flex space-x-2">
<Button2 color="green" size="xs" onClick={acceptDiff}>
Expand Down Expand Up @@ -150,7 +164,7 @@ export function AiToolbar() {
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
runAiWithStore();
runAi();
}
}}
/>
Expand All @@ -160,7 +174,7 @@ export function AiToolbar() {
size="xs"
className="dark:bg-purple-700 dark:hover:bg-purple-600 dark:text-purple-100"
disabled={isRunning}
onClick={runAiWithStore}
onClick={runAi}
data-session-activity={`Run AI: ${currentMode}`}
>
{!isRunning ? t`Submit` : "..."}
Expand Down
2 changes: 1 addition & 1 deletion app/src/components/ConvertOnPasteOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export function ConvertOnPasteOverlay() {
* This is positioned at the bottom across the whole screen
*/
function Overlay() {
const runAiWithStore = useRunAiWithStore();
const { runAi: runAiWithStore } = useRunAiWithStore();
const isRunning = usePromptStore((s) => s.isRunning);
const pasted = useEditorStore((s) => s.userPasted);
return (
Expand Down
43 changes: 23 additions & 20 deletions app/src/lib/runAi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,15 @@ function writeToEditor(text: string) {
* Runs an AI endpoint and streams the response back into the editor
*/
export async function runAi({
prompt,
endpoint,
prompt,
sid,
signal,
}: {
endpoint: string;
prompt: string;
endpoint: "prompt" | "convert" | "edit";
sid?: string;
signal?: AbortSignal;
}) {
let accumulated = "";

Expand All @@ -65,6 +67,7 @@ export async function runAi({
Authorization: sid ? `Bearer ${sid}` : "",
},
body: JSON.stringify({ prompt, document: useDoc.getState().text }),
signal,
})
.then((response) => {
if (response.ok && response.body) {
Expand All @@ -81,8 +84,8 @@ export async function runAi({
if (endpoint === "edit") {
// No setup...
} else {
editor.pushUndoStop(); // Make sure you can undo the changes
writeToEditor(""); // Clear the editor content
editor.pushUndoStop();
writeToEditor("");
}

const processText = ({
Expand All @@ -91,42 +94,42 @@ export async function runAi({
}: ReadableStreamReadResult<Uint8Array>): Promise<void> => {
if (done) {
setLastResult(accumulated);
resolve(accumulated);
return Promise.resolve();
}

const text = decoder.decode(value, { stream: true });
accumulated += text;

// If we are editing, we want to set the diff
if (endpoint === "edit") {
setDiff(accumulated);
} else {
// If we are not editing, we want to write the text to the editor
writeToEditor(accumulated);
}

return reader.read().then(processText);
return reader.read().then(processText).catch(reject);
};

reader
.read()
.then(processText)
.finally(() => {
editor.pushUndoStop();
resolve(accumulated);
});
reader.read().then(processText).catch(reject);
} else {
if (response.status === 429) {
reject(new Error(RATE_LIMIT_EXCEEDED));
} else {
reject(
new Error(
t`Sorry, there was an error converting the text to a flowchart. Try again later.`
)
);
}
reject(
new Error(
t`Sorry, there was an error converting the text to a flowchart. Try again later.`
)
);
}
})
.catch(reject);
.catch((error) => {
if (error.name === "AbortError") {
reject(new Error("Operation canceled"));
} else {
reject(error);
}
});
});
}

Expand Down
40 changes: 28 additions & 12 deletions app/src/lib/usePromptStore.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { create } from "zustand";
import { RATE_LIMIT_EXCEEDED, runAi } from "./runAi";
import { isError } from "./helpers";
import { useCallback, useContext } from "react";
import { useCallback, useContext, useState } from "react";
import { useHasProAccess } from "./hooks";
import { showPaywall } from "./usePaywallModalStore";
import { t } from "@lingui/macro";
Expand Down Expand Up @@ -90,11 +89,14 @@ export function useRunAiWithStore() {
const hasProAccess = useHasProAccess();
const customer = useContext(AppContext).customer;
const sid = customer?.subscription?.id;
const [abortController, setAbortController] =
useState<AbortController | null>(null);

const handleError = useCallback(
(error: Error) => {
if (!hasProAccess && error.message === RATE_LIMIT_EXCEEDED) {
// Show paywall
if (error.name === "AbortError") {
setError(t`Operation canceled`);
} else if (!hasProAccess && error.message === RATE_LIMIT_EXCEEDED) {
showPaywall({
title: t`Get Unlimited AI Requests`,
content: t`You've used all your free AI conversions. Upgrade to Pro for unlimited AI use, custom themes, private sharing, and more. Keep creating amazing flowcharts effortlessly!`,
Expand All @@ -113,25 +115,28 @@ export function useRunAiWithStore() {
[hasProAccess]
);

return useCallback(() => {
const runAiCallback = useCallback(() => {
const store = usePromptStore.getState();
if (store.isRunning) return;

// close the toolbar
setIsOpen(false);
startConvert();

// If we're creating, we need to unfreeze the editor
if (store.mode === "convert" || store.mode === "prompt") {
unfreezeDoc();
}

runAi({ endpoint: store.mode, prompt: store.currentText, sid })
.catch((err) => {
if (isError(err)) handleError(err);
})
const newAbortController = new AbortController();
setAbortController(newAbortController);

runAi({
endpoint: store.mode,
prompt: store.currentText,
sid,
signal: newAbortController.signal,
})
.catch(handleError)
.then((result) => {
// Just in case there is an error, run repair text on the result
if (result) {
const text = repairText(result);
if (text) {
Expand All @@ -142,6 +147,17 @@ export function useRunAiWithStore() {
.finally(() => {
stopConvert();
useEditorStore.setState({ userPasted: "" });
setAbortController(null);
});
}, [handleError, sid]);

const cancelAi = useCallback(() => {
if (abortController) {
abortController.abort();
stopConvert();
setAbortController(null);
}
}, [abortController]);

return { runAi: runAiCallback, cancelAi };
}
Loading