mirror of
https://github.com/makeplane/plane.git
synced 2026-07-12 21:40:18 +02:00
Merge pull request #890 from makeplane/chore-rephrase-ai
[WEB-2047] feat: editor AI options
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
# Python imports
|
||||
import os
|
||||
from enum import Enum
|
||||
|
||||
# Third party imports
|
||||
from openai import OpenAI
|
||||
@@ -12,47 +13,68 @@ from plane.ee.views.base import BaseAPIView
|
||||
from plane.ee.permissions import WorkspaceEntityPermission
|
||||
|
||||
|
||||
class Task(Enum):
|
||||
PARAPHRASE = "PARAPHRASE"
|
||||
SIMPLIFY = "SIMPLIFY"
|
||||
ELABORATE = "ELABORATE"
|
||||
SUMMARIZE = "SUMMARIZE"
|
||||
GET_TITLE = "GET_TITLE"
|
||||
TONE = "TONE"
|
||||
ASK_AI = "ASK_AI"
|
||||
|
||||
|
||||
class RephraseGrammarEndpoint(BaseAPIView):
|
||||
permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
# Function to get system prompt based on task
|
||||
def get_system_prompt(task, tone=None):
|
||||
if task == "paraphrase":
|
||||
return """
|
||||
Correct the grammar of the following text, strictly following these instructions:
|
||||
def get_system_prompt(self, task, casual_score=5, formal_score=5):
|
||||
if task == Task.PARAPHRASE.value:
|
||||
return (
|
||||
True,
|
||||
"""
|
||||
Correct the grammar of the following text and return the output in same structure as input but just changing the text by strictly following these instructions:
|
||||
1. Ensure the new text is grammatically correct.
|
||||
2. Maintain the original meaning of the text.
|
||||
3. Keep the same person in the text. First person remains first person, etc. If no person is mentioned, keep it the same as in the input text, whether third person or neutral.
|
||||
4. Do not provide any unrelated data apart from the grammatically correct original text.
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
elif task == "simplify":
|
||||
return """
|
||||
Simplify the following text, strictly adhering to these guidelines:
|
||||
elif task == Task.SIMPLIFY.value:
|
||||
return (
|
||||
True,
|
||||
"""
|
||||
Simplify the following text and return the output in same structure as input but just changing the text by strictly adhering to these guidelines:
|
||||
1. Make the text more concise without losing its core meaning.
|
||||
2. Remove unnecessary words and phrases.
|
||||
3. Break down complex sentences into simpler ones.
|
||||
4. Use simpler vocabulary where appropriate, without changing the overall tone.
|
||||
5. Ensure the simplified text is still grammatically correct.
|
||||
6. Do not add any new information not present in the original text.
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
elif task == "elaborate":
|
||||
return """
|
||||
Elaborate on the following text, carefully following these instructions:
|
||||
elif task == Task.ELABORATE.value:
|
||||
return (
|
||||
True,
|
||||
"""
|
||||
Elaborate on the following text and return the output in same structure as input but just changing the text by carefully following these instructions:
|
||||
1. Add relevant details, examples, or explanations to enrich the content.
|
||||
2. Expand on the main ideas while maintaining the original meaning and tone.
|
||||
3. Provide context where needed to enhance understanding.
|
||||
4. Ensure that your elaborations flow naturally with the existing text.
|
||||
5. Keep the overall structure and key points of the original text intact.
|
||||
6. Do not contradict any information in the original text.
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
elif task == "summarize":
|
||||
return """
|
||||
Summarize the following text, strictly adhering to these guidelines:
|
||||
elif task == Task.SUMMARIZE.value:
|
||||
return (
|
||||
True,
|
||||
"""
|
||||
Summarize the following text and return the output in same structure as input but just changing the text by strictly adhering to these guidelines:
|
||||
1. Condense the text into a highly concise summary that is approximately 10-15 percent of the length of the input text.
|
||||
2. Capture only the most essential information and main ideas.
|
||||
3. Use clear, straightforward language.
|
||||
@@ -61,91 +83,221 @@ class RephraseGrammarEndpoint(BaseAPIView):
|
||||
6. Do not introduce any new information not present in the original text.
|
||||
7. Ensure the summary provides a quick, easily digestible overview of the text's content.
|
||||
8. If the input text is very short (less than 100 words), aim for a summary of 1-2 sentences.
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
elif task == "get_title":
|
||||
return """
|
||||
Generate an appropriate title for the following text, strictly following these instructions:
|
||||
elif task == Task.GET_TITLE.value:
|
||||
return (
|
||||
True,
|
||||
"""
|
||||
Generate an appropriate title for the following text and return the output in same structure as input but just changing the text by strictly following these instructions:
|
||||
1. Create a concise and engaging title that captures the main theme or purpose of the content.
|
||||
2. Ensure the title is relevant and accurately represents the text's subject matter.
|
||||
3. Make the title attention-grabbing while maintaining accuracy.
|
||||
4. Keep the title brief, ideally no more than 6-8 words.
|
||||
5. Do not include any information in the title that is not present in or implied by the text.
|
||||
6. If the text is clearly part of a larger work or series, reflect that in the title if appropriate.
|
||||
"""
|
||||
""",
|
||||
)
|
||||
|
||||
elif task == "tone":
|
||||
elif task == Task.TONE.value:
|
||||
base_instructions = """
|
||||
Rewrite the following text in the specified tone, strictly adhering to these guidelines:
|
||||
Rewrite the following text according to the specified readability score and return the output in the same structure as the input, changing only the text. Strictly adhere to these guidelines:
|
||||
|
||||
1. Maintain the original meaning and key information of the text.
|
||||
2. Ensure the rewritten text is grammatically correct and coherent.
|
||||
3. Adapt the following elements to fit the desired tone:
|
||||
- Paragraph structure and length
|
||||
- Use of examples or anecdotes
|
||||
- Level of detail and explanation
|
||||
4. Consider the context and purpose of the text when adjusting the tone.
|
||||
5. Maintain consistency in tone throughout the entire text.
|
||||
6. Do not add or remove significant information from the original text.
|
||||
7. Adjust sentence structure and vocabulary to fit the desired tone without changing the core message.
|
||||
3. Consider the context and purpose of the text when adjusting the tone.
|
||||
4. Maintain consistency in style throughout the entire text.
|
||||
5. Do not add or remove significant information from the original text.
|
||||
6. Adjust the structure and length as needed to match the tone.
|
||||
"""
|
||||
tone_specific_instructions = {
|
||||
(10, 0): """
|
||||
Extremely Casual
|
||||
1. Use the simplest, most conversational language possible.
|
||||
2. Write as if speaking to a close friend or family member.
|
||||
3. Use lots of contractions, slang, and colloquialisms.
|
||||
4. Keep sentences very short and simple.
|
||||
5. Use active voice exclusively.
|
||||
6. Explain complex ideas using everyday analogies and examples.
|
||||
7. Address the reader directly and use a very friendly, personal tone.
|
||||
8. Use emotive language and exclamations where appropriate.
|
||||
""",
|
||||
(9, 1): """
|
||||
Very Casual
|
||||
1. Use simple, conversational language.
|
||||
2. Include contractions and some common colloquialisms.
|
||||
3. Keep sentences short and straightforward.
|
||||
4. Use mostly active voice.
|
||||
5. Explain any technical terms in simple language.
|
||||
6. Address the reader directly in a friendly manner.
|
||||
7. Use a personal, engaging tone throughout.
|
||||
""",
|
||||
(8, 2): """
|
||||
Casual
|
||||
1. Use everyday language with some more complex words.
|
||||
2. Mix short and medium-length sentences.
|
||||
3. Use contractions frequently.
|
||||
4. Incorporate some idiomatic expressions.
|
||||
5. Maintain a friendly, approachable tone.
|
||||
6. Use personal pronouns and address the reader directly.
|
||||
7. Simplify complex ideas but retain some field-specific terms with explanations.
|
||||
""",
|
||||
(7, 3): """
|
||||
Somewhat Casual
|
||||
1. Balance everyday language with more sophisticated vocabulary.
|
||||
2. Use a mix of simple and complex sentences.
|
||||
3. Include contractions in most cases.
|
||||
4. Maintain a friendly tone while introducing some more formal elements.
|
||||
5. Use mostly active voice, but include some passive constructions.
|
||||
6. Explain technical terms, but use them where appropriate.
|
||||
7. Address the reader directly, but less frequently than in more casual styles.
|
||||
""",
|
||||
(6, 4): """
|
||||
Neutral Leaning Casual
|
||||
1. Use a balanced mix of casual and formal language.
|
||||
2. Construct sentences of varying complexity.
|
||||
3. Use contractions in some cases, but not always.
|
||||
4. Maintain a friendly yet professional tone.
|
||||
5. Include technical terms where necessary, with brief explanations.
|
||||
6. Use a mix of active and passive voice.
|
||||
7. Address the reader directly occasionally, but not consistently.
|
||||
""",
|
||||
(5, 5): """
|
||||
Neutral
|
||||
1. Strike a perfect balance between casual and formal language.
|
||||
2. Use a varied vocabulary that includes both common and more sophisticated words.
|
||||
3. Construct sentences of mixed complexity.
|
||||
4. Use contractions sparingly.
|
||||
5. Maintain a tone that is neither overly friendly nor too formal.
|
||||
6. Include technical terms where appropriate, with explanations when necessary.
|
||||
7. Balance active and passive voice equally.
|
||||
8. Address the reader directly only when it serves a clear purpose.
|
||||
""",
|
||||
(4, 6): """
|
||||
Neutral Leaning Formal
|
||||
1. Use more sophisticated vocabulary, but keep some casual elements.
|
||||
2. Construct mostly complex sentences with some simpler ones for balance.
|
||||
3. Use contractions occasionally, but prefer full forms.
|
||||
4. Maintain a professional tone with hints of friendliness.
|
||||
5. Incorporate field-specific terminology with brief explanations.
|
||||
6. Use more passive voice constructions.
|
||||
7. Address the reader directly only when necessary for clarity or emphasis.
|
||||
""",
|
||||
(3, 7): """
|
||||
Somewhat Formal
|
||||
1. Use more advanced vocabulary and complex sentence structures.
|
||||
2. Minimize use of contractions.
|
||||
3. Maintain a professional and objective tone.
|
||||
4. Incorporate field-specific terminology freely, explaining only the most complex terms.
|
||||
5. Use passive voice more frequently.
|
||||
6. Avoid directly addressing the reader unless absolutely necessary.
|
||||
7. Present information in a more detached, analytical manner.
|
||||
""",
|
||||
(2, 8): """
|
||||
Formal
|
||||
1. Use sophisticated vocabulary and complex sentence structures.
|
||||
2. Avoid contractions entirely.
|
||||
3. Maintain a highly professional and objective tone.
|
||||
4. Use field-specific terminology without explanation, unless extremely specialized.
|
||||
5. Prefer passive voice in most cases.
|
||||
6. Never address the reader directly.
|
||||
7. Present information in a scholarly, analytical manner.
|
||||
""",
|
||||
(1, 9): """
|
||||
Very Formal
|
||||
1. Use advanced, academic vocabulary and intricate sentence structures.
|
||||
2. Construct complex, multi-clause sentences.
|
||||
3. Maintain a strictly professional, detached tone.
|
||||
4. Use extensive field-specific terminology and concepts.
|
||||
5. Employ passive voice predominantly.
|
||||
6. Present information in a highly analytical and abstract manner.
|
||||
7. Use impersonal constructions throughout.
|
||||
""",
|
||||
(0, 10): """
|
||||
Extremely Formal
|
||||
1. Use the most advanced, scholarly vocabulary possible.
|
||||
2. Construct highly complex, multi-layered sentences.
|
||||
3. Maintain an extremely formal, impersonal tone throughout.
|
||||
4. Use the most specialized terminology and abstract concepts without explanation.
|
||||
5. Use passive voice and impersonal constructions almost exclusively.
|
||||
6. Present information in the most academic, theoretical manner possible.
|
||||
7. Avoid any hint of casualness or personal address.
|
||||
""",
|
||||
}
|
||||
|
||||
if tone == "casual":
|
||||
return (
|
||||
base_instructions
|
||||
+ """
|
||||
For a casual tone:
|
||||
1. Use conversational and everyday language.
|
||||
2. Incorporate contractions, colloquialisms, and common idioms.
|
||||
3. Use active voice predominantly.
|
||||
4. Employ simpler sentence structures.
|
||||
5. Use more common vocabulary and explain technical terms in lay terms.
|
||||
6. Incorporate personal experiences and relatable scenarios where appropriate from the input.
|
||||
7. Aim for a Flesch-Kincaid grade level of 6-8.
|
||||
return True, base_instructions + tone_specific_instructions.get(
|
||||
(casual_score, formal_score)
|
||||
)
|
||||
|
||||
elif task == Task.ASK_AI.value:
|
||||
return (
|
||||
True,
|
||||
"""
|
||||
)
|
||||
elif tone == "formal":
|
||||
return (
|
||||
base_instructions
|
||||
+ """
|
||||
For a formal tone:
|
||||
1. Use professional and academic language.
|
||||
2. Avoid contractions, colloquialisms, and idioms.
|
||||
3. Maintain a respectful, objective, and impersonal voice.
|
||||
4. Use passive voice where appropriate.
|
||||
5. Employ more complex sentence structures.
|
||||
6. Utilize precise vocabulary and technical terms when relevant.
|
||||
7. Focus on facts, data, and logical arguments.
|
||||
8. Maintain technical terms and provide context if necessary.
|
||||
9. Aim for a Flesch-Kincaid grade level of 11-12 or higher.
|
||||
"""
|
||||
)
|
||||
else:
|
||||
You are an advanced AI assistant designed to provide optimal responses by integrating given context with your broad knowledge base. Your primary objectives are:
|
||||
|
||||
1. Thoroughly analyze and understand the provided context, which may include context, specific questions, code snippets, or any relevant information.
|
||||
2. Treat the given context as a critical input, using it to inform and guide your response.
|
||||
3. Leverage your extensive knowledge to complement and enhance your understanding of the context and to provide comprehensive, accurate answers.
|
||||
4. Seamlessly blend insights from the given context with your general knowledge, ensuring a cohesive and informative response.
|
||||
5. Adapt your response style and depth based on the nature of the context and the question asked.
|
||||
6. When dealing with code or technical context, provide explanations or solutions that are directly relevant and technically sound.
|
||||
7. Maintain clarity and conciseness in your responses while ensuring they are complete and informative.
|
||||
8. Use appropriate HTML tags for formatting only when it enhances readability or structure of the response.
|
||||
9. Respect privacy and avoid sensationalism when addressing sensitive topics.
|
||||
|
||||
Your goal is to deliver the most relevant, accurate, and helpful response possible, considering both the provided content and your broader understanding.
|
||||
""",
|
||||
)
|
||||
|
||||
else:
|
||||
return False, {
|
||||
"error": "Invalid task. Please provide a correct task name."
|
||||
}
|
||||
|
||||
def post(self, request, slug):
|
||||
# Get the task
|
||||
task = request.data.get("task", "grammar_check")
|
||||
|
||||
if task == Task.ASK_AI.value:
|
||||
context = request.data.get("text_input", "")
|
||||
user_prompt = request.data.get("query", "")
|
||||
|
||||
# Check inputs
|
||||
if not context or not user_prompt:
|
||||
return Response(
|
||||
{"error": "Invalid tone. Must be 'formal' or 'casual'."},
|
||||
{"error": "Query and text input are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
text_input = f"Context:\n\n{context}\n\nQuestion: {user_prompt}"
|
||||
else:
|
||||
return Response(
|
||||
{"error": "Invalid task. Please provide a correct task name."},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
text_input = request.data.get("text_input", "")
|
||||
|
||||
def post(self, request, slug):
|
||||
# Get the task and text input
|
||||
task = request.data.get("task", "grammar_check")
|
||||
text_input = request.data.get("text_input", "")
|
||||
# Check inputs
|
||||
if not text_input:
|
||||
return Response(
|
||||
{"error": "Text input is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# tone may be formal or casual
|
||||
tone = request.data.get("tone") if task == "tone" else None
|
||||
# Get casual and formal scores for the tone task
|
||||
casual_score = int(request.data.get("casual_score", 5))
|
||||
formal_score = int(request.data.get("formal_score", 5))
|
||||
|
||||
# Check the text input
|
||||
if not text_input:
|
||||
return Response(
|
||||
{"error": "Text input is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# Check the scores (only for TONE task)
|
||||
if task == Task.TONE.value:
|
||||
if (
|
||||
casual_score + formal_score != 10
|
||||
or casual_score < 0
|
||||
or formal_score < 0
|
||||
):
|
||||
return Response(
|
||||
{
|
||||
"error": "Invalid scores. casual_score and formal_score must sum to 10 and both must be non-negative."
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Get the configuration value
|
||||
OPENAI_API_KEY, GPT_ENGINE = get_configuration_value(
|
||||
@@ -164,7 +316,7 @@ class RephraseGrammarEndpoint(BaseAPIView):
|
||||
# Check the keys
|
||||
if not OPENAI_API_KEY or not GPT_ENGINE:
|
||||
return Response(
|
||||
{"error": "OpenAI API key and engine is required"},
|
||||
{"error": "OpenAI API key and engine are required"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
@@ -172,17 +324,23 @@ class RephraseGrammarEndpoint(BaseAPIView):
|
||||
message_list = [{"role": "user", "content": text_input}]
|
||||
|
||||
# Create the client
|
||||
client = OpenAI(
|
||||
api_key=OPENAI_API_KEY,
|
||||
client = OpenAI(api_key=OPENAI_API_KEY)
|
||||
|
||||
processed, response = self.get_system_prompt(
|
||||
task, casual_score, formal_score
|
||||
)
|
||||
|
||||
# If error
|
||||
if not processed:
|
||||
return Response(response, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# Create the completion
|
||||
completion = client.chat.completions.create(
|
||||
model=GPT_ENGINE,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": self.get_system_prompt(task, tone),
|
||||
"content": response,
|
||||
},
|
||||
*message_list,
|
||||
],
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
// extensions
|
||||
import { SideMenuHandleOptions, SideMenuPluginProps } from "@/extensions";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export const AIHandlePlugin = (options: SideMenuPluginProps): SideMenuHandleOptions => {
|
||||
const view = () => {};
|
||||
const domEvents = {};
|
||||
|
||||
return {
|
||||
view,
|
||||
domEvents,
|
||||
};
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./handle";
|
||||
@@ -1,4 +1,2 @@
|
||||
export * from "./ai-features";
|
||||
export * from "./issue-suggestions";
|
||||
export * from "./document-extensions";
|
||||
export * from "./ai-features";
|
||||
|
||||
@@ -14,13 +14,14 @@ import {
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
// components
|
||||
import { EditorAIMenu } from "@/ce/components/pages/editor/ai";
|
||||
import { PageContentBrowser, PageEditorTitle, PageContentLoader } from "@/components/pages";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// hooks
|
||||
import { useMember, useMention, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web components
|
||||
import { EditorAIMenu } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useIssueEmbed } from "@/plane-web/hooks/use-issue-embed";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// plane web constants
|
||||
import { AI_EDITOR_TASKS } from "@/ce/constants/ai";
|
||||
// helpers
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
// plane web constants
|
||||
import { AI_EDITOR_TASKS } from "@/plane-web/constants/ai";
|
||||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
|
||||
103
web/ee/components/pages/editor/ai/ask-pi-menu.tsx
Normal file
103
web/ee/components/pages/editor/ai/ask-pi-menu.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from "react";
|
||||
import { CircleArrowUp, CornerDownRight, RefreshCcw, Sparkles } from "lucide-react";
|
||||
// ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextReadOnlyEditor } from "@/components/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
|
||||
type Props = {
|
||||
handleInsertText: (insertOnNextLine: boolean) => void;
|
||||
handleRegenerate: () => Promise<void>;
|
||||
isRegenerating: boolean;
|
||||
response: string | undefined;
|
||||
};
|
||||
|
||||
export const AskPiMenu: React.FC<Props> = (props) => {
|
||||
const { handleInsertText, handleRegenerate, isRegenerating, response } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 px-4 py-3.5", {
|
||||
"items-start": response,
|
||||
})}
|
||||
>
|
||||
<span className="flex-shrink-0 size-7 grid place-items-center text-custom-text-200 rounded-full border border-custom-border-200">
|
||||
<Sparkles className="size-3" />
|
||||
</span>
|
||||
{response ? (
|
||||
<div>
|
||||
<RichTextReadOnlyEditor
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
id="editor-ai-response"
|
||||
initialValue={response}
|
||||
containerClassName="!p-0 border-none"
|
||||
editorClassName="!pl-0"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-custom-text-300 text-sm font-medium rounded hover:bg-custom-background-80 outline-none"
|
||||
onClick={() => handleInsertText(false)}
|
||||
>
|
||||
Replace selection
|
||||
</button>
|
||||
<Tooltip tooltipContent="Add to next line">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded hover:bg-custom-background-80 outline-none"
|
||||
onClick={() => handleInsertText(true)}
|
||||
>
|
||||
<CornerDownRight className="text-custom-text-300 size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Re-generate response">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded hover:bg-custom-background-80 outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRegenerate();
|
||||
}}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={cn("text-custom-text-300 size-4", {
|
||||
"animate-spin": isRegenerating,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-custom-text-200">Pi is answering...</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="py-3 px-4">
|
||||
<div className="flex items-center gap-2 border border-custom-border-200 rounded-md p-2">
|
||||
<span className="flex-shrink-0 size-3 grid place-items-center">
|
||||
<Sparkles className="size-3 text-custom-text-200" />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-transparent border-none outline-none placeholder:text-custom-text-400 text-sm"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Tell Pi what to do..."
|
||||
/>
|
||||
<span className="flex-shrink-0 size-4 grid place-items-center">
|
||||
<CircleArrowUp className="size-4 text-custom-text-200" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
2
web/ee/components/pages/editor/ai/index.ts
Normal file
2
web/ee/components/pages/editor/ai/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./ask-pi-menu";
|
||||
export * from "./menu";
|
||||
306
web/ee/components/pages/editor/ai/menu.tsx
Normal file
306
web/ee/components/pages/editor/ai/menu.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
"use client";
|
||||
|
||||
import React, { RefObject, useEffect, useRef, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ChevronRight, CornerDownRight, LucideIcon, PencilLine, RefreshCcw, Sparkles } from "lucide-react";
|
||||
// plane editor
|
||||
import { EditorRefApi } from "@plane/editor";
|
||||
// plane ui
|
||||
import { Tooltip } from "@plane/ui";
|
||||
// components
|
||||
import { RichTextReadOnlyEditor } from "@/components/editor";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
// plane web constants
|
||||
import { AI_EDITOR_TASKS } from "@/plane-web/constants/ai";
|
||||
// services
|
||||
import { AIService, TTaskPayload } from "@/services/ai.service";
|
||||
const aiService = new AIService();
|
||||
|
||||
type Props = {
|
||||
editorRef: RefObject<EditorRefApi>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const MENU_ITEMS: {
|
||||
icon: LucideIcon;
|
||||
key: AI_EDITOR_TASKS;
|
||||
label: string;
|
||||
}[] = [
|
||||
{
|
||||
key: AI_EDITOR_TASKS.PARAPHRASE,
|
||||
icon: PencilLine,
|
||||
label: "Paraphrase",
|
||||
},
|
||||
{
|
||||
key: AI_EDITOR_TASKS.SIMPLIFY,
|
||||
icon: PencilLine,
|
||||
label: "Simplify",
|
||||
},
|
||||
{
|
||||
key: AI_EDITOR_TASKS.ELABORATE,
|
||||
icon: PencilLine,
|
||||
label: "Elaborate",
|
||||
},
|
||||
{
|
||||
key: AI_EDITOR_TASKS.SUMMARIZE,
|
||||
icon: PencilLine,
|
||||
label: "Summarize",
|
||||
},
|
||||
{
|
||||
key: AI_EDITOR_TASKS.GET_TITLE,
|
||||
icon: PencilLine,
|
||||
label: "Get title",
|
||||
},
|
||||
// {
|
||||
// key: AI_EDITOR_TASKS.TONE,
|
||||
// icon: PencilLine,
|
||||
// label: "Tone adjustment",
|
||||
// },
|
||||
];
|
||||
|
||||
const LOADING_TEXTS: {
|
||||
[key in AI_EDITOR_TASKS]: string;
|
||||
} = {
|
||||
[AI_EDITOR_TASKS.PARAPHRASE]: "Pi is paraphrasing",
|
||||
[AI_EDITOR_TASKS.SIMPLIFY]: "Pi is simplifying",
|
||||
[AI_EDITOR_TASKS.ELABORATE]: "Pi is elaborating",
|
||||
[AI_EDITOR_TASKS.SUMMARIZE]: "Pi is summarizing",
|
||||
[AI_EDITOR_TASKS.GET_TITLE]: "Pi is getting title",
|
||||
[AI_EDITOR_TASKS.TONE]: "Pi is adjusting tone",
|
||||
};
|
||||
|
||||
const TONES_LIST = [
|
||||
{
|
||||
key: "default",
|
||||
label: "Default",
|
||||
casual_score: 5,
|
||||
formal_score: 5,
|
||||
},
|
||||
{
|
||||
key: "professional",
|
||||
label: "💼 Professional",
|
||||
casual_score: 0,
|
||||
formal_score: 10,
|
||||
},
|
||||
{
|
||||
key: "casual",
|
||||
label: "😃 Casual",
|
||||
casual_score: 10,
|
||||
formal_score: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const EditorAIMenu: React.FC<Props> = (props) => {
|
||||
const { editorRef, isOpen, onClose } = props;
|
||||
// states
|
||||
const [activeTask, setActiveTask] = useState<AI_EDITOR_TASKS | null>(null);
|
||||
const [response, setResponse] = useState<string | undefined>(undefined);
|
||||
const [isRegenerating, setIsRegenerating] = useState(false);
|
||||
// refs
|
||||
const responseContainerRef = useRef<HTMLDivElement>(null);
|
||||
// params
|
||||
const { workspaceSlug } = useParams();
|
||||
const handleGenerateResponse = async (payload: TTaskPayload) => {
|
||||
if (!workspaceSlug) return;
|
||||
await aiService.performEditorTask(workspaceSlug.toString(), payload).then((res) => setResponse(res.response));
|
||||
};
|
||||
// handle task click
|
||||
const handleClick = async (key: AI_EDITOR_TASKS) => {
|
||||
const selection = editorRef.current?.getSelectedText();
|
||||
if (!selection || activeTask === key) return;
|
||||
setActiveTask(key);
|
||||
setResponse(undefined);
|
||||
setIsRegenerating(false);
|
||||
await handleGenerateResponse({
|
||||
task: key,
|
||||
text_input: selection,
|
||||
});
|
||||
};
|
||||
// handle re-generate response
|
||||
const handleRegenerate = async () => {
|
||||
const selection = editorRef.current?.getSelectedText();
|
||||
if (!selection || !activeTask) return;
|
||||
setIsRegenerating(true);
|
||||
await handleGenerateResponse({
|
||||
task: activeTask,
|
||||
text_input: selection,
|
||||
})
|
||||
.then(() =>
|
||||
responseContainerRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
})
|
||||
)
|
||||
.finally(() => setIsRegenerating(false));
|
||||
};
|
||||
// handle re-generate response
|
||||
const handleToneChange = async (key: string) => {
|
||||
const selectedTone = TONES_LIST.find((t) => t.key === key);
|
||||
const selection = editorRef.current?.getSelectedText();
|
||||
if (!selectedTone || !selection || !activeTask) return;
|
||||
setResponse(undefined);
|
||||
setIsRegenerating(false);
|
||||
await handleGenerateResponse({
|
||||
casual_score: selectedTone.casual_score,
|
||||
formal_score: selectedTone.formal_score,
|
||||
task: activeTask,
|
||||
text_input: selection,
|
||||
}).then(() =>
|
||||
responseContainerRef.current?.scrollTo({
|
||||
top: 0,
|
||||
behavior: "smooth",
|
||||
})
|
||||
);
|
||||
};
|
||||
// handle replace selected text with the response
|
||||
const handleInsertText = (insertOnNextLine: boolean) => {
|
||||
if (!response) return;
|
||||
editorRef.current?.insertText(response, insertOnNextLine);
|
||||
onClose();
|
||||
};
|
||||
|
||||
// reset on close
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setActiveTask(null);
|
||||
setResponse(undefined);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-72 w-[210px] rounded-md border-[0.5px] border-custom-border-300 bg-custom-background-100 shadow-custom-shadow-rg transition-all overflow-hidden",
|
||||
{
|
||||
"w-[700px] divide-x divide-custom-border-200": activeTask,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex-shrink-0 w-[210px] overflow-y-auto px-2 py-2.5 transition-all">
|
||||
{MENU_ITEMS.map((item) => {
|
||||
const isActiveTask = activeTask === item.key;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2 truncate rounded px-1 py-1.5 text-xs text-custom-text-200 hover:bg-custom-background-80 transition-colors",
|
||||
{
|
||||
"bg-custom-background-80": isActiveTask,
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleClick(item.key);
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0 flex items-center gap-2 truncate">
|
||||
<item.icon className="flex-shrink-0 size-3" />
|
||||
{item.label}
|
||||
</span>
|
||||
<ChevronRight
|
||||
className={cn("flex-shrink-0 size-3 opacity-0 pointer-events-none transition-opacity", {
|
||||
"opacity-100 pointer-events-auto": isActiveTask,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
ref={responseContainerRef}
|
||||
className={cn("flex-shrink-0 w-0 overflow-hidden transition-all", {
|
||||
"w-[490px] overflow-auto vertical-scrollbar scrollbar-sm": activeTask,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={cn("flex items-center gap-3 px-4 py-3.5", {
|
||||
"items-start": response,
|
||||
})}
|
||||
>
|
||||
<span className="flex-shrink-0 size-7 grid place-items-center text-custom-text-200 rounded-full border border-custom-border-200">
|
||||
<Sparkles className="size-3" />
|
||||
</span>
|
||||
{response ? (
|
||||
<div>
|
||||
<RichTextReadOnlyEditor
|
||||
displayConfig={{
|
||||
fontSize: "small-font",
|
||||
}}
|
||||
id="editor-ai-response"
|
||||
initialValue={response}
|
||||
containerClassName="!p-0 border-none"
|
||||
editorClassName="!pl-0"
|
||||
/>
|
||||
<div className="mt-3 flex items-center gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 text-custom-text-300 text-sm font-medium rounded hover:bg-custom-background-80 outline-none"
|
||||
onClick={() => handleInsertText(false)}
|
||||
>
|
||||
Replace selection
|
||||
</button>
|
||||
<Tooltip tooltipContent="Add to next line">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded hover:bg-custom-background-80 outline-none"
|
||||
onClick={() => handleInsertText(true)}
|
||||
>
|
||||
<CornerDownRight className="text-custom-text-300 size-4" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip tooltipContent="Re-generate response">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-shrink-0 size-6 grid place-items-center rounded hover:bg-custom-background-80 outline-none"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRegenerate();
|
||||
}}
|
||||
disabled={isRegenerating}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={cn("text-custom-text-300 size-4", {
|
||||
"animate-spin": isRegenerating,
|
||||
})}
|
||||
/>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-custom-text-200">
|
||||
{activeTask ? LOADING_TEXTS[activeTask] : "Pi is writing"}...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="sticky bottom-0 w-full bg-custom-background-100 pl-[54.8px] py-2 flex items-center gap-2">
|
||||
{TONES_LIST.map((tone) => (
|
||||
<button
|
||||
key={tone.key}
|
||||
type="button"
|
||||
className={cn(
|
||||
"p-1 text-xs text-custom-text-200 font-medium bg-custom-background-80 rounded transition-colors outline-none",
|
||||
{
|
||||
"bg-custom-primary-100/20 text-custom-primary-100": tone.key === "default",
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToneChange(tone.key);
|
||||
}}
|
||||
>
|
||||
{tone.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect } from "react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
// document-editor
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
EditorReadOnlyRefApi,
|
||||
EditorRefApi,
|
||||
IMarking,
|
||||
TAIMenuProps,
|
||||
TDisplayConfig,
|
||||
} from "@plane/editor";
|
||||
// types
|
||||
import { IUserLite } from "@plane/types";
|
||||
@@ -19,7 +21,7 @@ import { cn } from "@/helpers/common.helper";
|
||||
import { useMember, useUser, useWorkspace } from "@/hooks/store";
|
||||
import { usePageFilters } from "@/hooks/use-page-filters";
|
||||
// plane web components
|
||||
import { IssueEmbedCard } from "@/plane-web/components/pages";
|
||||
import { EditorAIMenu, IssueEmbedCard } from "@/plane-web/components/pages";
|
||||
// plane web hooks
|
||||
import { useEditorFlagging } from "@/plane-web/hooks/use-editor-flagging";
|
||||
import { useWorkspaceIssueEmbed } from "@/plane-web/hooks/use-workspace-issue-embed";
|
||||
@@ -84,10 +86,20 @@ export const WorkspacePageEditorBody: React.FC<Props> = observer((props) => {
|
||||
// editor flaggings
|
||||
const { documentEditor } = useEditorFlagging(workspaceSlug?.toString());
|
||||
// page filters
|
||||
const { isFullWidth } = usePageFilters();
|
||||
const { fontSize, fontStyle, isFullWidth } = usePageFilters();
|
||||
// issue-embed
|
||||
const { fetchIssues } = useWorkspaceIssueEmbed(workspaceSlug?.toString() ?? "");
|
||||
|
||||
const displayConfig: TDisplayConfig = {
|
||||
fontSize,
|
||||
fontStyle,
|
||||
};
|
||||
|
||||
const getAIMenu = useCallback(
|
||||
({ isOpen, onClose }: TAIMenuProps) => <EditorAIMenu editorRef={editorRef} isOpen={isOpen} onClose={onClose} />,
|
||||
[editorRef]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateMarkings(pageDescription ?? "<p></p>");
|
||||
}, [pageDescription, updateMarkings]);
|
||||
@@ -145,6 +157,7 @@ export const WorkspacePageEditorBody: React.FC<Props> = observer((props) => {
|
||||
value={pageDescriptionYJS}
|
||||
ref={editorRef}
|
||||
containerClassName="p-0 pb-64"
|
||||
displayConfig={displayConfig}
|
||||
editorClassName="pl-10"
|
||||
onChange={handleDescriptionChange}
|
||||
mentionHandler={{
|
||||
@@ -183,6 +196,9 @@ export const WorkspacePageEditorBody: React.FC<Props> = observer((props) => {
|
||||
},
|
||||
}}
|
||||
disabledExtensions={documentEditor}
|
||||
aiHandler={{
|
||||
menu: getAIMenu,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<DocumentReadOnlyEditorWithRef
|
||||
@@ -191,6 +207,7 @@ export const WorkspacePageEditorBody: React.FC<Props> = observer((props) => {
|
||||
initialValue={pageDescription ?? "<p></p>"}
|
||||
handleEditorReady={handleReadOnlyEditorReady}
|
||||
containerClassName="p-0 pb-64 border-none"
|
||||
displayConfig={displayConfig}
|
||||
editorClassName="pl-10"
|
||||
mentionHandler={{
|
||||
highlights: mentionHighlights,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./ai";
|
||||
export * from "./embed";
|
||||
export * from "./editor-body";
|
||||
export * from "./page-root";
|
||||
|
||||
8
web/ee/constants/ai.ts
Normal file
8
web/ee/constants/ai.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum AI_EDITOR_TASKS {
|
||||
PARAPHRASE = "PARAPHRASE",
|
||||
SIMPLIFY = "SIMPLIFY",
|
||||
ELABORATE = "ELABORATE",
|
||||
SUMMARIZE = "SUMMARIZE",
|
||||
GET_TITLE = "GET_TITLE",
|
||||
TONE = "TONE",
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { StoreContext } from "@/lib/store-context";
|
||||
export enum E_FEATURE_FLAGS {
|
||||
BULK_OPS = "BULK_OPS",
|
||||
BULK_OPS_ADVANCED = "BULK_OPS_ADVANCED",
|
||||
EDITOR_AI_OPS = "EDITOR_AI_OPS",
|
||||
ESTIMATE_WITH_TIME = "ESTIMATE_WITH_TIME",
|
||||
ISSUE_TYPE_DISPLAY = "ISSUE_TYPE_DISPLAY",
|
||||
ISSUE_TYPE_SETTINGS = "ISSUE_TYPE_SETTINGS",
|
||||
|
||||
@@ -5,13 +5,6 @@ import { useFlag } from "@/plane-web/hooks/store";
|
||||
|
||||
/**
|
||||
* @description extensions disabled in various editors
|
||||
* @returns
|
||||
* ```ts
|
||||
* {
|
||||
* documentEditor: TExtensions[]
|
||||
* richTextEditor: TExtensions[]
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export const useEditorFlagging = (
|
||||
workspaceSlug: string
|
||||
@@ -20,11 +13,11 @@ export const useEditorFlagging = (
|
||||
richTextEditor: TExtensions[];
|
||||
} => {
|
||||
const isIssueEmbedEnabled = useFlag(workspaceSlug, "PAGE_ISSUE_EMBEDS");
|
||||
const isEditorAIOpsEnabled = useFlag(workspaceSlug, "EDITOR_AI_OPS");
|
||||
// extensions disabled in the document editor
|
||||
const documentEditor: TExtensions[] = [];
|
||||
if (!isIssueEmbedEnabled) documentEditor.push("issue-embed");
|
||||
// Temporarily disabled
|
||||
documentEditor.push("ai");
|
||||
if (!isEditorAIOpsEnabled) documentEditor.push("ai");
|
||||
|
||||
return {
|
||||
documentEditor,
|
||||
|
||||
Reference in New Issue
Block a user