mirror of
https://github.com/makeplane/plane
synced 2024-06-14 14:31:34 +00:00
chore: ai for issue description (#575)
* feat: block sync * chore: ai assistant for issue description
This commit is contained in:
parent
2f69761130
commit
96910e1897
@ -19,6 +19,7 @@ type Props = {
|
|||||||
content: string;
|
content: string;
|
||||||
htmlContent?: string;
|
htmlContent?: string;
|
||||||
onResponse: (response: string) => void;
|
onResponse: (response: string) => void;
|
||||||
|
projectId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FormData = {
|
type FormData = {
|
||||||
@ -37,12 +38,13 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
content,
|
content,
|
||||||
htmlContent,
|
htmlContent,
|
||||||
onResponse,
|
onResponse,
|
||||||
|
projectId,
|
||||||
}) => {
|
}) => {
|
||||||
const [response, setResponse] = useState("");
|
const [response, setResponse] = useState("");
|
||||||
const [invalidResponse, setInvalidResponse] = useState(false);
|
const [invalidResponse, setInvalidResponse] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug, projectId } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
const { setToastAlert } = useToast();
|
const { setToastAlert } = useToast();
|
||||||
|
|
||||||
@ -80,7 +82,7 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
await aiService
|
await aiService
|
||||||
.createGptTask(workspaceSlug as string, projectId as string, {
|
.createGptTask(workspaceSlug as string, projectId as string, {
|
||||||
prompt: content && content !== "" ? content : "",
|
prompt: content && content !== "" ? content : htmlContent ?? "",
|
||||||
task: formData.task,
|
task: formData.task,
|
||||||
})
|
})
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
@ -98,75 +100,77 @@ export const GptAssistantModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`absolute ${inset} z-20 w-full rounded-[10px] border bg-white p-4 shadow ${
|
className={`absolute ${inset} z-20 w-full space-y-4 rounded-[10px] border bg-white p-4 shadow ${
|
||||||
isOpen ? "block" : "hidden"
|
isOpen ? "block" : "hidden"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<form onSubmit={handleSubmit(handleResponse)} className="space-y-4">
|
{((content && content !== "") || htmlContent) && (
|
||||||
{content && content !== "" && (
|
<div className="text-sm page-block-section">
|
||||||
<div className="text-sm">
|
Content:
|
||||||
Content:
|
<RemirrorRichTextEditor
|
||||||
<RemirrorRichTextEditor
|
value={htmlContent ?? <p>{content}</p>}
|
||||||
value={htmlContent ?? <p>{content}</p>}
|
customClassName="-mx-3 -my-3"
|
||||||
customClassName="-mx-3 -my-3"
|
noBorder
|
||||||
noBorder
|
borderOnFocus={false}
|
||||||
borderOnFocus={false}
|
editable={false}
|
||||||
editable={false}
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{response !== "" && (
|
|
||||||
<div className="text-sm">
|
|
||||||
Response:
|
|
||||||
<RemirrorRichTextEditor
|
|
||||||
value={`<p>${response}</p>`}
|
|
||||||
customClassName="-mx-3 -my-3"
|
|
||||||
noBorder
|
|
||||||
borderOnFocus={false}
|
|
||||||
editable={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{invalidResponse && (
|
|
||||||
<div className="text-sm text-red-500">
|
|
||||||
No response could be generated. This may be due to insufficient content or task
|
|
||||||
information. Please try again.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Input
|
|
||||||
type="text"
|
|
||||||
name="task"
|
|
||||||
register={register}
|
|
||||||
placeholder={`${
|
|
||||||
content && content !== ""
|
|
||||||
? "Tell AI what action to perform on this content..."
|
|
||||||
: "Ask AI anything..."
|
|
||||||
}`}
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
|
|
||||||
{response !== "" && (
|
|
||||||
<PrimaryButton
|
|
||||||
onClick={() => {
|
|
||||||
onResponse(response);
|
|
||||||
onClose();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Use this response
|
|
||||||
</PrimaryButton>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
|
|
||||||
<PrimaryButton type="submit" loading={isSubmitting}>
|
|
||||||
{isSubmitting
|
|
||||||
? "Generating response..."
|
|
||||||
: response === ""
|
|
||||||
? "Generate response"
|
|
||||||
: "Generate again"}
|
|
||||||
</PrimaryButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
{response !== "" && (
|
||||||
|
<div className="text-sm page-block-section">
|
||||||
|
Response:
|
||||||
|
<RemirrorRichTextEditor
|
||||||
|
value={`<p>${response}</p>`}
|
||||||
|
customClassName="-mx-3 -my-3"
|
||||||
|
noBorder
|
||||||
|
borderOnFocus={false}
|
||||||
|
editable={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invalidResponse && (
|
||||||
|
<div className="text-sm text-red-500">
|
||||||
|
No response could be generated. This may be due to insufficient content or task
|
||||||
|
information. Please try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
name="task"
|
||||||
|
register={register}
|
||||||
|
placeholder={`${
|
||||||
|
content && content !== ""
|
||||||
|
? "Tell AI what action to perform on this content..."
|
||||||
|
: "Ask AI anything..."
|
||||||
|
}`}
|
||||||
|
autoComplete="off"
|
||||||
|
/>
|
||||||
|
<div className={`flex gap-2 ${response === "" ? "justify-end" : "justify-between"}`}>
|
||||||
|
{response !== "" && (
|
||||||
|
<PrimaryButton
|
||||||
|
onClick={() => {
|
||||||
|
onResponse(response);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Use this response
|
||||||
|
</PrimaryButton>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<SecondaryButton onClick={onClose}>Close</SecondaryButton>
|
||||||
|
<PrimaryButton
|
||||||
|
type="button"
|
||||||
|
onClick={handleSubmit(handleResponse)}
|
||||||
|
loading={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting
|
||||||
|
? "Generating response..."
|
||||||
|
: response === ""
|
||||||
|
? "Generate response"
|
||||||
|
: "Generate again"}
|
||||||
|
</PrimaryButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,7 +3,7 @@ import { FC, useCallback, useEffect, useState } from "react";
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
|
|
||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// components
|
// components
|
||||||
import { Loader, TextArea } from "components/ui";
|
import { Loader, TextArea } from "components/ui";
|
||||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||||
@ -42,6 +42,8 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
watch,
|
watch,
|
||||||
setValue,
|
setValue,
|
||||||
reset,
|
reset,
|
||||||
|
register,
|
||||||
|
control,
|
||||||
formState: { errors },
|
formState: { errors },
|
||||||
} = useForm<IIssue>({
|
} = useForm<IIssue>({
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@ -64,19 +66,18 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
[handleFormSubmit]
|
[handleFormSubmit]
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
// useEffect(() => {
|
||||||
const alertUser = (e: BeforeUnloadEvent) => {
|
// const alertUser = (e: BeforeUnloadEvent) => {
|
||||||
console.log("beforeunload");
|
// e.preventDefault();
|
||||||
e.preventDefault();
|
// e.returnValue = "";
|
||||||
e.returnValue = "";
|
// return "Are you sure you want to leave?";
|
||||||
return "Are you sure you want to leave?";
|
// };
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("beforeunload", alertUser);
|
// window.addEventListener("beforeunload", alertUser);
|
||||||
return () => {
|
// return () => {
|
||||||
window.removeEventListener("beforeunload", alertUser);
|
// window.removeEventListener("beforeunload", alertUser);
|
||||||
};
|
// };
|
||||||
}, [isSubmitting]);
|
// }, [isSubmitting]);
|
||||||
|
|
||||||
// reset form values
|
// reset form values
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -94,7 +95,7 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
id="name"
|
id="name"
|
||||||
name="name"
|
name="name"
|
||||||
placeholder="Enter issue name"
|
placeholder="Enter issue name"
|
||||||
value={watch("name")}
|
register={register}
|
||||||
onFocus={() => setCharacterLimit(true)}
|
onFocus={() => setCharacterLimit(true)}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
setCharacterLimit(false);
|
setCharacterLimit(false);
|
||||||
@ -108,9 +109,6 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
onChange={(e) => {
|
|
||||||
setValue("name", e.target.value);
|
|
||||||
}}
|
|
||||||
required={true}
|
required={true}
|
||||||
className="min-h-10 block w-full resize-none
|
className="min-h-10 block w-full resize-none
|
||||||
overflow-hidden rounded border-none bg-transparent
|
overflow-hidden rounded border-none bg-transparent
|
||||||
@ -131,26 +129,34 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = ({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span>{errors.name ? errors.name.message : null}</span>
|
<span>{errors.name ? errors.name.message : null}</span>
|
||||||
<RemirrorRichTextEditor
|
<Controller
|
||||||
value={
|
name="description"
|
||||||
watch("description") && watch("description") !== ""
|
control={control}
|
||||||
? watch("description")
|
render={({ field: { value } }) => (
|
||||||
: watch("description_html")
|
<RemirrorRichTextEditor
|
||||||
}
|
value={
|
||||||
placeholder="Describe the issue..."
|
!value ||
|
||||||
onBlur={() => {
|
value === "" ||
|
||||||
setIsSubmitting(true);
|
(typeof value === "object" && Object.keys(value).length === 0)
|
||||||
handleSubmit(handleDescriptionFormSubmit)()
|
? watch("description_html")
|
||||||
.then(() => {
|
: value
|
||||||
setIsSubmitting(false);
|
}
|
||||||
})
|
onJSONChange={(jsonValue) => setValue("description", jsonValue)}
|
||||||
.catch(() => {
|
onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)}
|
||||||
setIsSubmitting(false);
|
onBlur={() => {
|
||||||
});
|
setIsSubmitting(true);
|
||||||
}}
|
handleSubmit(handleDescriptionFormSubmit)()
|
||||||
onJSONChange={(json) => setValue("description", json)}
|
.then(() => {
|
||||||
onHTMLChange={(html) => setValue("description_html", html)}
|
setIsSubmitting(false);
|
||||||
editable={!isNotAllowed}
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder="Describe the issue..."
|
||||||
|
editable={!isNotAllowed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute -bottom-8 right-0 text-sm text-gray-500 ${
|
className={`absolute -bottom-8 right-0 text-sm text-gray-500 ${
|
||||||
|
@ -7,6 +7,7 @@ import { useRouter } from "next/router";
|
|||||||
// react-hook-form
|
// react-hook-form
|
||||||
import { Controller, useForm } from "react-hook-form";
|
import { Controller, useForm } from "react-hook-form";
|
||||||
// components
|
// components
|
||||||
|
import { GptAssistantModal } from "components/core";
|
||||||
import {
|
import {
|
||||||
IssueAssigneeSelect,
|
IssueAssigneeSelect,
|
||||||
IssueLabelSelect,
|
IssueLabelSelect,
|
||||||
@ -22,7 +23,7 @@ import { CreateLabelModal } from "components/labels";
|
|||||||
// ui
|
// ui
|
||||||
import { CustomMenu, Input, Loader, PrimaryButton, SecondaryButton } from "components/ui";
|
import { CustomMenu, Input, Loader, PrimaryButton, SecondaryButton } from "components/ui";
|
||||||
// icons
|
// icons
|
||||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
import { SparklesIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||||
// helpers
|
// helpers
|
||||||
import { cosineSimilarity } from "helpers/string.helper";
|
import { cosineSimilarity } from "helpers/string.helper";
|
||||||
// types
|
// types
|
||||||
@ -31,7 +32,7 @@ import type { IIssue } from "types";
|
|||||||
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
const RemirrorRichTextEditor = dynamic(() => import("components/rich-text-editor"), {
|
||||||
ssr: false,
|
ssr: false,
|
||||||
loading: () => (
|
loading: () => (
|
||||||
<Loader>
|
<Loader className="mt-4">
|
||||||
<Loader.Item height="12rem" width="100%" />
|
<Loader.Item height="12rem" width="100%" />
|
||||||
</Loader>
|
</Loader>
|
||||||
),
|
),
|
||||||
@ -81,6 +82,8 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||||||
const [labelModal, setLabelModal] = useState(false);
|
const [labelModal, setLabelModal] = useState(false);
|
||||||
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
const [parentIssueListModalOpen, setParentIssueListModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const [gptAssistantModal, setGptAssistantModal] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { workspaceSlug } = router.query;
|
const { workspaceSlug } = router.query;
|
||||||
|
|
||||||
@ -115,6 +118,13 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAiAssistance = async (response: string) => {
|
||||||
|
if (!workspaceSlug || !projectId) return;
|
||||||
|
|
||||||
|
setValue("description", {});
|
||||||
|
setValue("description_html", `${watch("description_html")}<p>${response}</p>`);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setFocus("name");
|
setFocus("name");
|
||||||
|
|
||||||
@ -233,7 +243,17 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="relative">
|
||||||
|
<div className="flex justify-end -mb-2 mr-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="-mr-2 flex items-center gap-1 rounded px-1.5 py-1 text-xs hover:bg-gray-100"
|
||||||
|
onClick={() => setGptAssistantModal((prevData) => !prevData)}
|
||||||
|
>
|
||||||
|
<SparklesIcon className="h-4 w-4" />
|
||||||
|
AI
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<Controller
|
<Controller
|
||||||
name="description"
|
name="description"
|
||||||
control={control}
|
control={control}
|
||||||
@ -250,6 +270,15 @@ export const IssueForm: FC<IssueFormProps> = ({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
<GptAssistantModal
|
||||||
|
isOpen={gptAssistantModal}
|
||||||
|
handleClose={() => setGptAssistantModal(false)}
|
||||||
|
inset="top-2 left-0"
|
||||||
|
content=""
|
||||||
|
htmlContent={watch("description_html")}
|
||||||
|
onResponse={handleAiAssistance}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<Controller
|
<Controller
|
||||||
|
@ -333,6 +333,7 @@ export const SinglePageBlock: React.FC<Props> = ({ block, projectDetails }) => {
|
|||||||
content={block.description_stripped}
|
content={block.description_stripped}
|
||||||
htmlContent={block.description_html}
|
htmlContent={block.description_html}
|
||||||
onResponse={handleAiAssistance}
|
onResponse={handleAiAssistance}
|
||||||
|
projectId={projectId as string}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -50,8 +50,6 @@ const ProjectViews: NextPage<UserAuth> = (props) => {
|
|||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(views)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppLayout
|
<AppLayout
|
||||||
meta={{
|
meta={{
|
||||||
@ -88,11 +86,7 @@ const ProjectViews: NextPage<UserAuth> = (props) => {
|
|||||||
<h3 className="text-3xl font-semibold text-black">Views</h3>
|
<h3 className="text-3xl font-semibold text-black">Views</h3>
|
||||||
<div className="rounded-[10px] border">
|
<div className="rounded-[10px] border">
|
||||||
{views.map((view) => (
|
{views.map((view) => (
|
||||||
<SingleViewItem
|
<SingleViewItem key={view.id} view={view} setSelectedView={setSelectedView} />
|
||||||
key={view.id}
|
|
||||||
view={view}
|
|
||||||
setSelectedView={setSelectedView}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
Loading…
Reference in New Issue
Block a user