From c0b23bf8f9f7edaf0d2297eeea585f5beec9e9b7 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Wed, 28 Aug 2024 20:23:49 +0530 Subject: [PATCH 01/74] wip: AI integration --- .../chat/ChatInput/TextFormattingMenu.tsx | 6 +- .../feature/chat/ChatInput/ToolPanel.tsx | 2 +- frontend/src/types/Raven/RavenSettings.ts | 8 ++ frontend/src/types/RavenAI/RavenAIFunction.ts | 17 +++ frontend/src/types/RavenBot/RavenBot.ts | 8 +- .../RavenChannelManagement/RavenChannel.ts | 6 + pyproject.toml | 3 +- raven/ai/ai.py | 72 +++++++++++ raven/ai/handler.py | 119 ++++++++++++++++++ raven/ai/openai_client.py | 29 +++++ raven/modules.txt | 3 +- .../raven_settings/raven_settings.json | 44 ++++++- .../doctype/raven_settings/raven_settings.py | 4 + raven/raven_ai/__init__.py | 0 raven/raven_ai/doctype/__init__.py | 0 .../doctype/raven_ai_function/__init__.py | 0 .../raven_ai_function/raven_ai_function.js | 8 ++ .../raven_ai_function/raven_ai_function.json | 53 ++++++++ .../raven_ai_function/raven_ai_function.py | 21 ++++ .../test_raven_ai_function.py | 9 ++ .../doctype/raven_bot/raven_bot.json | 54 +++++++- .../raven_bot/doctype/raven_bot/raven_bot.py | 4 + .../doctype/raven_channel/raven_channel.json | 34 ++++- .../doctype/raven_channel/raven_channel.py | 3 + .../doctype/raven_message/raven_message.py | 105 ++++++++++++++-- 25 files changed, 587 insertions(+), 25 deletions(-) create mode 100644 frontend/src/types/RavenAI/RavenAIFunction.ts create mode 100644 raven/ai/ai.py create mode 100644 raven/ai/handler.py create mode 100644 raven/ai/openai_client.py create mode 100644 raven/raven_ai/__init__.py create mode 100644 raven/raven_ai/doctype/__init__.py create mode 100644 raven/raven_ai/doctype/raven_ai_function/__init__.py create mode 100644 raven/raven_ai/doctype/raven_ai_function/raven_ai_function.js create mode 100644 raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json create mode 100644 raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py create mode 100644 raven/raven_ai/doctype/raven_ai_function/test_raven_ai_function.py diff --git a/frontend/src/components/feature/chat/ChatInput/TextFormattingMenu.tsx b/frontend/src/components/feature/chat/ChatInput/TextFormattingMenu.tsx index 18ed29114..7316137db 100644 --- a/frontend/src/components/feature/chat/ChatInput/TextFormattingMenu.tsx +++ b/frontend/src/components/feature/chat/ChatInput/TextFormattingMenu.tsx @@ -1,5 +1,5 @@ import { useCurrentEditor } from '@tiptap/react' -import { BiBold, BiCodeAlt, BiCodeBlock ,BiHighlight, BiItalic, BiListOl, BiListUl, BiStrikethrough, BiUnderline, BiSolidQuoteAltRight } from 'react-icons/bi' +import { BiBold, BiCodeAlt, BiCodeBlock, BiHighlight, BiItalic, BiListOl, BiListUl, BiStrikethrough, BiUnderline, BiSolidQuoteAltRight } from 'react-icons/bi' import { DEFAULT_BUTTON_STYLE, ICON_PROPS } from './ToolPanel' import { Box, Flex, IconButton, Separator, Tooltip } from '@radix-ui/themes' import { getKeyboardMetaKeyString } from '@/utils/layout/keyboardKey' @@ -14,7 +14,7 @@ export const TextFormattingMenu = () => { return } return ( - + { - + editor.chain().focus().toggleCodeBlock().run()} aria-label='code block' diff --git a/frontend/src/components/feature/chat/ChatInput/ToolPanel.tsx b/frontend/src/components/feature/chat/ChatInput/ToolPanel.tsx index b469e66c7..f3abdef7a 100644 --- a/frontend/src/components/feature/chat/ChatInput/ToolPanel.tsx +++ b/frontend/src/components/feature/chat/ChatInput/ToolPanel.tsx @@ -12,7 +12,7 @@ export const ToolPanel = (props: FlexProps) => { diff --git a/frontend/src/types/Raven/RavenSettings.ts b/frontend/src/types/Raven/RavenSettings.ts index fd5a93234..bfc117632 100644 --- a/frontend/src/types/Raven/RavenSettings.ts +++ b/frontend/src/types/Raven/RavenSettings.ts @@ -16,6 +16,14 @@ export interface RavenSettings{ show_raven_on_desk?: 0 | 1 /** Tenor API Key : Data */ tenor_api_key?: string + /** Enable AI Integration : Check */ + enable_ai_integration?: 0 | 1 + /** OpenAI Organisation ID : Data */ + openai_organisation_id?: string + /** OpenAI API Key : Password */ + openai_api_key?: string + /** OpenAI Project ID : Data - If not set, the integration will use the default project */ + openai_project_id?: string /** Automatically Create a Channel for each Department : Check - If checked, a channel will be created in Raven for each department and employees will be synced with Raven Users. */ auto_create_department_channel?: 0 | 1 /** Department Channel Type : Select */ diff --git a/frontend/src/types/RavenAI/RavenAIFunction.ts b/frontend/src/types/RavenAI/RavenAIFunction.ts new file mode 100644 index 000000000..d34e58822 --- /dev/null +++ b/frontend/src/types/RavenAI/RavenAIFunction.ts @@ -0,0 +1,17 @@ + +export interface RavenAIFunction{ + name: string + creation: string + modified: string + owner: string + modified_by: string + docstatus: 0 | 1 | 2 + parent?: string + parentfield?: string + parenttype?: string + idx?: number + /** Function Path : Data */ + function_path: string + /** Pass parameters as JSON : Check - If checked, the params will be passed as a JSON object instead of named parameters */ + pass_parameters_as_json?: 0 | 1 +} \ No newline at end of file diff --git a/frontend/src/types/RavenBot/RavenBot.ts b/frontend/src/types/RavenBot/RavenBot.ts index 32b092caa..c091b58cb 100644 --- a/frontend/src/types/RavenBot/RavenBot.ts +++ b/frontend/src/types/RavenBot/RavenBot.ts @@ -1,7 +1,7 @@ export interface RavenBot{ - creation: string name: string + creation: string modified: string owner: string modified_by: string @@ -22,4 +22,10 @@ export interface RavenBot{ is_standard?: 0 | 1 /** Module : Link - Module Def */ module?: string + /** Is AI Bot? : Check */ + is_ai_bot?: 0 | 1 + /** OpenAI Assistant ID : Data */ + openai_assistant_id?: string + /** Allow Bot to Write Documents : Check */ + allow_bot_to_write_documents?: 0 | 1 } \ No newline at end of file diff --git a/frontend/src/types/RavenChannelManagement/RavenChannel.ts b/frontend/src/types/RavenChannelManagement/RavenChannel.ts index a9756fd10..f4a9f0af3 100644 --- a/frontend/src/types/RavenChannelManagement/RavenChannel.ts +++ b/frontend/src/types/RavenChannelManagement/RavenChannel.ts @@ -34,4 +34,10 @@ export interface RavenChannel{ last_message_timestamp?: string /** Last Message Details : JSON */ last_message_details?: any + /** Is AI Thread : Check */ + is_ai_thread?: 0 | 1 + /** OpenAI Thread ID : Data */ + openai_thread_id?: string + /** Thread Bot : Link - Raven Bot */ + thread_bot?: string } \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a55a07050..d3725cd58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,8 @@ requires-python = ">=3.10" readme = "README.md" dynamic = ["version"] dependencies = [ - "linkpreview~=0.9.0" + "linkpreview~=0.9.0", + "openai" ] [build-system] diff --git a/raven/ai/ai.py b/raven/ai/ai.py new file mode 100644 index 000000000..cc2234e2f --- /dev/null +++ b/raven/ai/ai.py @@ -0,0 +1,72 @@ +import frappe + +from raven.ai.handler import stream_response +from raven.ai.openai_client import get_open_ai_client + + +def handle_bot_dm(message, bot): + """ + Function to handle direct messages to the bot. + + We need to start a new thread with the message and create a new conversation in OpenAI + """ + + client = get_open_ai_client() + + # TODO: Handle various message types + ai_thread = client.beta.threads.create( + messages=[ + { + "role": "user", + "content": message.content, + "metadata": {"user": message.owner, "message": message.name}, + } + ], + metadata={ + "bot": bot.name, + "channel": message.channel_id, + "user": message.owner, + "message": message.name, + }, + ) + + thread_channel = frappe.get_doc( + { + "doctype": "Raven Channel", + "channel_name": message.name, + "type": "Private", + "is_thread": 1, + "is_ai_thread": 1, + "openai_thread_id": ai_thread.id, + "thread_bot": bot.name, + } + ).insert() + + # Update the message to mark it as a thread + message.is_thread = 1 + message.save() + + frappe.db.commit() # We need to commit here since the response will be streamed, and hence might take a while + + stream_response(ai_thread_id=ai_thread.id, bot=bot, channel_id=thread_channel.name) + + +def handle_ai_thread_message(message, channel): + """ + Function to handle messages in an AI thread + + When a new message is sent, we need to send it to the OpenAI API and then stream the response + """ + + client = get_open_ai_client() + + client.beta.threads.messages.create( + thread_id=channel.openai_thread_id, + role="user", + content=message.content, + metadata={"user": message.owner, "message": message.name}, + ) + + bot = frappe.get_doc("Raven Bot", channel.thread_bot) + + stream_response(ai_thread_id=channel.openai_thread_id, bot=bot, channel_id=channel.name) diff --git a/raven/ai/handler.py b/raven/ai/handler.py new file mode 100644 index 000000000..3f6252c72 --- /dev/null +++ b/raven/ai/handler.py @@ -0,0 +1,119 @@ +import json + +import frappe +from openai import AssistantEventHandler, OpenAI +from openai.types.beta.threads import Text, TextDelta +from typing_extensions import override + +from raven.ai.openai_client import get_open_ai_client + + +def stream_response(ai_thread_id: str, bot, channel_id: str): + + client = get_open_ai_client() + + assistant_id = bot.openai_assistant_id + + class EventHandler(AssistantEventHandler): + @override + def on_text_done(self, text: Text): + bot.send_message(channel_id=channel_id, text=text.value) + + print(client.beta.threads.runs.retrieve(thread_id=ai_thread_id, run_id=self.current_run.id)) + + @override + def on_event(self, event): + # Retrieve events that are denoted with 'requires_action' + # since these will have our tool_calls + if event.event == "thread.run.requires_action": + run_id = event.data.id # Retrieve the run ID from the event data + self.handle_requires_action(event.data, run_id) + + def handle_requires_action(self, data, run_id): + tool_outputs = [] + + for tool in data.required_action.submit_tool_outputs.tool_calls: + + args = json.loads(tool.function.arguments) + + function_output = {} + + function = None + + try: + function = frappe.get_cached_doc("Raven AI Function", tool.function.name) + function_path = function.function_path + except frappe.DoesNotExistError: + function_path = None + + if not function_path: + tool_outputs.append({"tool_call_id": tool.id, "output": "Function not found"}) + + function_name = frappe.get_attr(function_path) + + # When calling the function, we need to pass the arguments as named params/json + # Args is a dictionary of the form {"param_name": "param_value"} + + if bot.allow_bot_to_write_documents: + # We can commit to the database if writes are allowed + if function.pass_parameters_as_json: + function_output = function_name(args) + else: + function_output = function_name(**args) + else: + # We need to savepoint and then rollback + frappe.db.savepoint(run_id + "_" + tool.id) + if function.pass_parameters_as_json: + function_output = function_name(args) + else: + function_output = function_name(**args) + frappe.db.rollback(save_point=run_id + "_" + tool.id) + + tool_outputs.append( + {"tool_call_id": tool.id, "output": json.dumps(function_output, default=str)} + ) + + # Submit all tool_outputs at the same time + self.submit_tool_outputs(tool_outputs, run_id) + + def submit_tool_outputs(self, tool_outputs, run_id): + # Use the submit_tool_outputs_stream helper + with client.beta.threads.runs.submit_tool_outputs_stream( + thread_id=self.current_run.thread_id, + run_id=self.current_run.id, + tool_outputs=tool_outputs, + event_handler=EventHandler(), + ) as stream: + for text in stream.text_deltas: + print(text, end="", flush=True) + print() + + # We need to get the instructions from the bot + instructions = get_instructions(bot) + with client.beta.threads.runs.stream( + thread_id=ai_thread_id, + assistant_id=assistant_id, + event_handler=EventHandler(), + instructions=instructions, + ) as stream: + stream.until_done() + + +def get_instructions(bot): + + if not bot.instruction: + return None + + vars = get_variables_for_instructions(bot) + + instructions = frappe.render_template(bot.instruction, vars) + return instructions + + +def get_variables_for_instructions(bot): + return { + "user_first_name": "Nikhil", + "company": "The Commit Company (Demo)", + "employee_id": "HR-EMP-00001", + "department": "Product - TCCD", + } diff --git a/raven/ai/openai_client.py b/raven/ai/openai_client.py new file mode 100644 index 000000000..94f30ce83 --- /dev/null +++ b/raven/ai/openai_client.py @@ -0,0 +1,29 @@ +import frappe +from openai import OpenAI + + +def get_open_ai_client(): + """ + Get the OpenAI client + """ + + raven_settings = frappe.get_cached_doc("Raven Settings") + + if not raven_settings.enable_ai_integration: + frappe.throw("AI Integration is not enabled") + + openai_api_key = raven_settings.get_password("openai_api_key") + + if raven_settings.openai_project_id: + client = OpenAI( + organization=raven_settings.openai_organisation_id, + project=raven_settings.openai_project_id, + api_key=openai_api_key, + ) + + return client + + else: + client = OpenAI(api_key=openai_api_key, organization=raven_settings.openai_organisation_id) + + return client diff --git a/raven/modules.txt b/raven/modules.txt index 54ebc74a0..4e7a2a38a 100644 --- a/raven/modules.txt +++ b/raven/modules.txt @@ -2,4 +2,5 @@ Raven Raven Messaging Raven Channel Management Raven Bot -Raven Integrations \ No newline at end of file +Raven Integrations +Raven AI \ No newline at end of file diff --git a/raven/raven/doctype/raven_settings/raven_settings.json b/raven/raven/doctype/raven_settings/raven_settings.json index 735543484..15a2afc6c 100644 --- a/raven/raven/doctype/raven_settings/raven_settings.json +++ b/raven/raven/doctype/raven_settings/raven_settings.json @@ -12,6 +12,12 @@ "integrations_tab", "integrations_section", "tenor_api_key", + "ai_section", + "enable_ai_integration", + "openai_organisation_id", + "openai_api_key", + "openai_project_id", + "column_break_occp", "frappe_hr_tab", "auto_create_department_channel", "department_channel_type", @@ -79,12 +85,48 @@ "fieldname": "show_if_a_user_is_on_leave", "fieldtype": "Check", "label": "Show if a user is on leave" + }, + { + "fieldname": "ai_section", + "fieldtype": "Section Break", + "label": "AI" + }, + { + "default": "0", + "fieldname": "enable_ai_integration", + "fieldtype": "Check", + "label": "Enable AI Integration" + }, + { + "depends_on": "eval:doc.enable_ai_integration;", + "fieldname": "openai_organisation_id", + "fieldtype": "Data", + "label": "OpenAI Organisation ID", + "mandatory_depends_on": "eval:doc.enable_ai_integration;" + }, + { + "depends_on": "eval:doc.enable_ai_integration;", + "fieldname": "openai_api_key", + "fieldtype": "Password", + "label": "OpenAI API Key", + "mandatory_depends_on": "eval:doc.enable_ai_integration;" + }, + { + "depends_on": "eval:doc.enable_ai_integration;", + "description": "If not set, the integration will use the default project", + "fieldname": "openai_project_id", + "fieldtype": "Data", + "label": "OpenAI Project ID" + }, + { + "fieldname": "column_break_occp", + "fieldtype": "Column Break" } ], "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2024-07-03 16:53:42.621934", + "modified": "2024-08-23 05:28:21.854302", "modified_by": "Administrator", "module": "Raven", "name": "Raven Settings", diff --git a/raven/raven/doctype/raven_settings/raven_settings.py b/raven/raven/doctype/raven_settings/raven_settings.py index ced56fb1a..e13a3300c 100644 --- a/raven/raven/doctype/raven_settings/raven_settings.py +++ b/raven/raven/doctype/raven_settings/raven_settings.py @@ -17,6 +17,10 @@ class RavenSettings(Document): auto_add_system_users: DF.Check auto_create_department_channel: DF.Check department_channel_type: DF.Literal["Public", "Private"] + enable_ai_integration: DF.Check + openai_api_key: DF.Password | None + openai_organisation_id: DF.Data | None + openai_project_id: DF.Data | None show_if_a_user_is_on_leave: DF.Check show_raven_on_desk: DF.Check tenor_api_key: DF.Data | None diff --git a/raven/raven_ai/__init__.py b/raven/raven_ai/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/raven_ai/doctype/__init__.py b/raven/raven_ai/doctype/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/raven_ai/doctype/raven_ai_function/__init__.py b/raven/raven_ai/doctype/raven_ai_function/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.js b/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.js new file mode 100644 index 000000000..c8dfa330f --- /dev/null +++ b/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Raven AI Function", { +// refresh(frm) { + +// }, +// }); diff --git a/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json b/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json new file mode 100644 index 000000000..fd4000c45 --- /dev/null +++ b/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.json @@ -0,0 +1,53 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "prompt", + "creation": "2024-08-23 08:43:26.356348", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "function_path", + "pass_parameters_as_json" + ], + "fields": [ + { + "fieldname": "function_path", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Function Path", + "reqd": 1 + }, + { + "default": "0", + "description": "If checked, the params will be passed as a JSON object instead of named parameters", + "fieldname": "pass_parameters_as_json", + "fieldtype": "Check", + "label": "Pass parameters as JSON" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-08-23 17:47:52.841530", + "modified_by": "Administrator", + "module": "Raven AI", + "name": "Raven AI Function", + "naming_rule": "Set by user", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py b/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py new file mode 100644 index 000000000..e405a9031 --- /dev/null +++ b/raven/raven_ai/doctype/raven_ai_function/raven_ai_function.py @@ -0,0 +1,21 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RavenAIFunction(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + function_path: DF.Data + pass_parameters_as_json: DF.Check + # end: auto-generated types + + pass diff --git a/raven/raven_ai/doctype/raven_ai_function/test_raven_ai_function.py b/raven/raven_ai/doctype/raven_ai_function/test_raven_ai_function.py new file mode 100644 index 000000000..badc11beb --- /dev/null +++ b/raven/raven_ai/doctype/raven_ai_function/test_raven_ai_function.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestRavenAIFunction(FrappeTestCase): + pass diff --git a/raven/raven_bot/doctype/raven_bot/raven_bot.json b/raven/raven_bot/doctype/raven_bot/raven_bot.json index 0788426fb..80bffa41b 100644 --- a/raven/raven_bot/doctype/raven_bot/raven_bot.json +++ b/raven/raven_bot/doctype/raven_bot/raven_bot.json @@ -14,7 +14,15 @@ "column_break_lhoo", "description", "is_standard", - "module" + "module", + "ai_tab", + "ai_section", + "is_ai_bot", + "openai_assistant_id", + "column_break_khmi", + "allow_bot_to_write_documents", + "section_break_lwkx", + "instruction" ], "fields": [ { @@ -63,12 +71,54 @@ { "fieldname": "column_break_lhoo", "fieldtype": "Column Break" + }, + { + "fieldname": "ai_section", + "fieldtype": "Section Break" + }, + { + "default": "0", + "fieldname": "is_ai_bot", + "fieldtype": "Check", + "label": "Is AI Bot?" + }, + { + "depends_on": "eval: doc.is_ai_bot;", + "fieldname": "openai_assistant_id", + "fieldtype": "Data", + "label": "OpenAI Assistant ID", + "mandatory_depends_on": "eval: doc.is_ai_bot;" + }, + { + "fieldname": "column_break_khmi", + "fieldtype": "Column Break" + }, + { + "default": "0", + "fieldname": "allow_bot_to_write_documents", + "fieldtype": "Check", + "label": "Allow Bot to Write Documents" + }, + { + "fieldname": "ai_tab", + "fieldtype": "Tab Break", + "label": "AI" + }, + { + "fieldname": "section_break_lwkx", + "fieldtype": "Section Break" + }, + { + "description": "You can use Jinja variables here to customize the instruction to the bot at run time.", + "fieldname": "instruction", + "fieldtype": "Long Text", + "label": "Instruction" } ], "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2024-03-29 23:16:44.406120", + "modified": "2024-08-23 19:59:02.021835", "modified_by": "Administrator", "module": "Raven Bot", "name": "Raven Bot", diff --git a/raven/raven_bot/doctype/raven_bot/raven_bot.py b/raven/raven_bot/doctype/raven_bot/raven_bot.py index 373c22b81..932b17b93 100644 --- a/raven/raven_bot/doctype/raven_bot/raven_bot.py +++ b/raven/raven_bot/doctype/raven_bot/raven_bot.py @@ -16,11 +16,15 @@ class RavenBot(Document): if TYPE_CHECKING: from frappe.types import DF + allow_bot_to_write_documents: DF.Check bot_name: DF.Data description: DF.SmallText | None image: DF.AttachImage | None + instruction: DF.LongText | None + is_ai_bot: DF.Check is_standard: DF.Check module: DF.Link | None + openai_assistant_id: DF.Data | None raven_user: DF.Link | None # end: auto-generated types diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json index 55d3a665e..1b2b2f48d 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.json +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.json @@ -24,7 +24,11 @@ "section_break_wlnt", "last_message_timestamp", "column_break_eckt", - "last_message_details" + "last_message_details", + "ai_tab", + "is_ai_thread", + "openai_thread_id", + "thread_bot" ], "fields": [ { @@ -142,6 +146,32 @@ "fieldtype": "Check", "label": "Is Thread", "read_only": 1 + }, + { + "fieldname": "ai_tab", + "fieldtype": "Tab Break", + "label": "AI" + }, + { + "default": "0", + "fieldname": "is_ai_thread", + "fieldtype": "Check", + "in_standard_filter": 1, + "label": "Is AI Thread", + "read_only": 1 + }, + { + "fieldname": "openai_thread_id", + "fieldtype": "Data", + "label": "OpenAI Thread ID", + "read_only": 1 + }, + { + "fieldname": "thread_bot", + "fieldtype": "Link", + "label": "Thread Bot", + "options": "Raven Bot", + "read_only": 1 } ], "index_web_pages_for_search": 1, @@ -155,7 +185,7 @@ "link_fieldname": "channel_id" } ], - "modified": "2024-08-18 20:45:00.510115", + "modified": "2024-08-23 07:35:09.979962", "modified_by": "Administrator", "module": "Raven Channel Management", "name": "Raven Channel", diff --git a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py index 99bef6715..987c244ae 100644 --- a/raven/raven_channel_management/doctype/raven_channel/raven_channel.py +++ b/raven/raven_channel_management/doctype/raven_channel/raven_channel.py @@ -17,6 +17,7 @@ class RavenChannel(Document): channel_description: DF.SmallText | None channel_name: DF.Data + is_ai_thread: DF.Check is_archived: DF.Check is_direct_message: DF.Check is_self_message: DF.Check @@ -26,6 +27,8 @@ class RavenChannel(Document): last_message_timestamp: DF.Datetime | None linked_doctype: DF.Link | None linked_document: DF.DynamicLink | None + openai_thread_id: DF.Data | None + thread_bot: DF.Link | None type: DF.Literal["Private", "Public", "Open"] # end: auto-generated types diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 15cd42613..18154446d 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -10,6 +10,7 @@ from frappe.utils import get_datetime, get_system_timezone from pytz import timezone, utc +from raven.ai.ai import handle_ai_thread_message, handle_bot_dm from raven.notification import send_notification_to_topic, send_notification_to_user from raven.utils import track_channel_visit @@ -61,7 +62,7 @@ def before_validate(self): except Exception: pass - if not self.is_new(): + if not self.is_new() and not self.flags.is_ai_streaming: # this is not a new message, so it's a previous message being edited old_doc = self.get_doc_before_save() if old_doc.text != self.text: @@ -120,6 +121,73 @@ def after_insert(self): # TODO: Enqueue this self.publish_unread_count_event() + self.handle_ai_message() + + def handle_ai_message(self): + + # If the message was sent by a bot, do not call the function + if self.is_bot_message: + return + + # If AI Integration is not enabled, do not call the function + raven_settings = frappe.get_cached_doc("Raven Settings") + if not raven_settings.enable_ai_integration: + return + + # Check if this channel is an AI Thread channel + + channel_doc = frappe.get_cached_doc("Raven Channel", self.channel_id) + + is_ai_thread = channel_doc.is_ai_thread + + if is_ai_thread and channel_doc.openai_thread_id: + + handle_ai_thread_message(self, channel_doc) + # frappe.enqueue(method=handle_ai_thread_message, + # message=self, + # channel=channel_doc, + # job_name="handle_ai_thread_message") + + return + + # If not a part of a AI Thread, then check if this is a DM to a bot - if yes, then we should create a new thread + + is_dm = channel_doc.is_direct_message + + # Only DMs to bots need to be handled (for now) + + if not is_dm: + return + + # Get the bot user + peer_user = frappe.db.get_value( + "Raven Channel Member", + {"channel_id": self.channel_id, "user_id": ("!=", self.owner)}, + "user_id", + ) + + if not peer_user: + return + + # Get the bot user doc + peer_user_doc = frappe.get_cached_doc("Raven User", peer_user) + + if peer_user_doc.type != "Bot" or not peer_user_doc.bot: + return + + bot = frappe.get_cached_doc("Raven Bot", peer_user_doc.bot) + + if not bot.is_ai_bot: + return + + frappe.enqueue( + method=handle_bot_dm, + message=self, + bot=bot, + job_name="handle_bot_dm", + at_front=True, + ) + def publish_unread_count_event(self): frappe.db.set_value( "Raven Channel", self.channel_id, "last_message_timestamp", self.creation, update_modified=False @@ -151,18 +219,21 @@ def publish_unread_count_event(self): {"channel_id": self.channel_id, "user_id": ("!=", frappe.session.user)}, "user_id", ) - peer_user_id = frappe.get_cached_value("Raven User", peer_raven_user, "user") - frappe.publish_realtime( - "raven:unread_channel_count_updated", - { - "channel_id": self.channel_id, - "play_sound": True, - "sent_by": self.owner, - }, - user=peer_user_id, - after_commit=True, - ) + peer_user_doc = frappe.get_cached_doc("Raven User", peer_raven_user) + + if peer_user_doc.type == "User": + + frappe.publish_realtime( + "raven:unread_channel_count_updated", + { + "channel_id": self.channel_id, + "play_sound": True, + "sent_by": self.owner, + }, + user=peer_user_doc.user, + after_commit=True, + ) # Need to send this to sender as well since they need to update the last message timestamp frappe.publish_realtime( @@ -263,12 +334,18 @@ def send_notification_for_direct_message(self): if not peer_raven_user: return + peer_raven_user_doc = frappe.get_cached_doc("Raven User", peer_raven_user) + + # Do not send notification to a bot + if peer_raven_user_doc.type == "Bot": + return + message = self.get_notification_message_content() owner_name = self.get_message_owner_name() send_notification_to_user( - user_id=peer_raven_user, + user_id=peer_raven_user_doc.user, user_image_id=self.owner, title=owner_name, message=message, @@ -382,6 +459,7 @@ def on_update(self): "message_details": { "text": self.text, "content": self.content, + "channel_id": self.channel_id, "file": self.file, "poll_id": self.poll_id, "message_type": self.message_type, @@ -424,6 +502,7 @@ def on_update(self): "message_id": self.name, "message_details": { "text": self.text, + "channel_id": self.channel_id, "content": self.content, "file": self.file, "message_type": self.message_type, From 07f920bab260d1626811d95864d67fe77917d6d7 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Mon, 2 Sep 2024 19:49:55 +0530 Subject: [PATCH 02/74] chore: linting --- raven/api/raven_mobile.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/raven/api/raven_mobile.py b/raven/api/raven_mobile.py index f0432bb83..dfdcb2ab0 100644 --- a/raven/api/raven_mobile.py +++ b/raven/api/raven_mobile.py @@ -1,5 +1,6 @@ import frappe + @frappe.whitelist(allow_guest=True) def get_client_id(): - return frappe.db.get_single_value("Raven Settings", "oauth_client") \ No newline at end of file + return frappe.db.get_single_value("Raven Settings", "oauth_client") From 8a17760ec418484c1e720524fefc1dac8df86229 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Mon, 2 Sep 2024 20:10:05 +0530 Subject: [PATCH 03/74] chore: semgrep --- raven/ai/ai.py | 4 ++-- raven/ai/openai_client.py | 3 ++- raven/api/events.py | 3 ++- raven/api/notification.py | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/raven/ai/ai.py b/raven/ai/ai.py index cc2234e2f..38ac3cdab 100644 --- a/raven/ai/ai.py +++ b/raven/ai/ai.py @@ -45,8 +45,8 @@ def handle_bot_dm(message, bot): # Update the message to mark it as a thread message.is_thread = 1 message.save() - - frappe.db.commit() # We need to commit here since the response will be streamed, and hence might take a while + # nosemgrep We need to commit here since the response will be streamed, and hence might take a while + frappe.db.commit() stream_response(ai_thread_id=ai_thread.id, bot=bot, channel_id=thread_channel.name) diff --git a/raven/ai/openai_client.py b/raven/ai/openai_client.py index 94f30ce83..e58cca55b 100644 --- a/raven/ai/openai_client.py +++ b/raven/ai/openai_client.py @@ -1,4 +1,5 @@ import frappe +from frappe import _ from openai import OpenAI @@ -10,7 +11,7 @@ def get_open_ai_client(): raven_settings = frappe.get_cached_doc("Raven Settings") if not raven_settings.enable_ai_integration: - frappe.throw("AI Integration is not enabled") + frappe.throw(_("AI Integration is not enabled")) openai_api_key = raven_settings.get_password("openai_api_key") diff --git a/raven/api/events.py b/raven/api/events.py index 56b704a2a..7069aafb8 100644 --- a/raven/api/events.py +++ b/raven/api/events.py @@ -1,4 +1,5 @@ import frappe +from frappe import _ @frappe.whitelist() @@ -19,7 +20,7 @@ def create_event( ) if not google_calendar: - frappe.throw("Google Calendar not found for the current user") + frappe.throw(_("Google Calendar not found for the current user")) event = frappe.get_doc( { diff --git a/raven/api/notification.py b/raven/api/notification.py index e65c88e0b..340912e5a 100644 --- a/raven/api/notification.py +++ b/raven/api/notification.py @@ -1,4 +1,5 @@ import frappe +from frappe import _ @frappe.whitelist() @@ -20,4 +21,4 @@ def toggle_push_notification_for_channel(member: str, allow_notifications: 0 | 1 return member_doc else: - frappe.throw("Push notifications are not supported in the current framework version") + frappe.throw(_("Push notifications are not supported in the current framework version")) From 7c2225dad56112a9e46ae17a2af0cec3e8f519b3 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Mon, 2 Sep 2024 20:44:02 +0530 Subject: [PATCH 04/74] fix: do not trigger AI for polls --- raven/ai/ai.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/raven/ai/ai.py b/raven/ai/ai.py index 38ac3cdab..787e57577 100644 --- a/raven/ai/ai.py +++ b/raven/ai/ai.py @@ -14,6 +14,15 @@ def handle_bot_dm(message, bot): client = get_open_ai_client() # TODO: Handle various message types + + # If the message is a poll, send a message to the user that we don't support polls for AI yet + + if message.message_type == "Poll": + bot.send_message( + channel_id=message.channel_id, + text="Sorry, I don't support polls yet. Please send a text message or file.", + ) + return ai_thread = client.beta.threads.create( messages=[ { From 5a9e1fa58cab633569c31041d61e82f10e2cd4f6 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Wed, 4 Sep 2024 20:00:10 +0530 Subject: [PATCH 05/74] chore: deps --- yarn.lock | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index a5d31c029..41e27ddd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5384,7 +5384,16 @@ sourcemap-codec@^1.4.8: resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5457,7 +5466,14 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6075,7 +6091,16 @@ workbox-window@7.1.0, workbox-window@^7.1.0: "@types/trusted-types" "^2.0.2" workbox-core "7.1.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== From 2d9f90df3dbbc972dc2201dd9a1f413ab1c3dc21 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Wed, 11 Sep 2024 20:38:20 +0530 Subject: [PATCH 06/74] feat: handle file uploads for AI bot --- .../feature/file-upload/FileDrop.tsx | 12 ++- .../threads/ThreadDrawer/ThreadMessages.tsx | 66 ++++++++------ raven/ai/ai.py | 91 ++++++++++++++----- .../doctype/raven_message/raven_message.py | 24 +++-- 4 files changed, 130 insertions(+), 63 deletions(-) diff --git a/frontend/src/components/feature/file-upload/FileDrop.tsx b/frontend/src/components/feature/file-upload/FileDrop.tsx index e6fc1411d..8564e410c 100644 --- a/frontend/src/components/feature/file-upload/FileDrop.tsx +++ b/frontend/src/components/feature/file-upload/FileDrop.tsx @@ -22,7 +22,9 @@ export interface FileDropProps extends FlexProps { accept?: Accept, /** Maximum file size in mb that can be selected */ maxFileSize?: number, - children?: React.ReactNode + children?: React.ReactNode, + height?: string, + width?: string } /** @@ -31,7 +33,7 @@ export interface FileDropProps extends FlexProps { */ export const FileDrop = forwardRef((props: FileDropProps, ref) => { - const { files, onFileChange, maxFiles, accept, maxFileSize, children, ...compProps } = props + const { files, onFileChange, maxFiles, accept, maxFileSize, children, height, width, ...compProps } = props const [onDragEnter, setOnDragEnter] = useState(false) @@ -77,7 +79,7 @@ export const FileDrop = forwardRef((props: FileDropProps, ref) => { { align='center' justify='center' className={clsx("fixed top-14 border-2 border-dashed rounded-md border-gray-6 dark:bg-[#171923AA] bg-[#F7FAFCAA]", - "h-[calc(100vh-72px)]", - "w-[calc(100vw-var(--sidebar-width)-var(--space-6))]", + height ?? "h-[calc(100vh-72px)]", + width ?? "w-[calc(100vw-var(--sidebar-width)-var(--space-6))]", )} style={{ zIndex: 9999 diff --git a/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx b/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx index 776d0fac2..e3bfe3ba2 100644 --- a/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx +++ b/frontend/src/components/feature/threads/ThreadDrawer/ThreadMessages.tsx @@ -5,7 +5,7 @@ import useFileUpload from "../../chat/ChatInput/FileInput/useFileUpload" import Tiptap from "../../chat/ChatInput/Tiptap" import { useSendMessage } from "../../chat/ChatInput/useSendMessage" import { ReplyMessageBox } from "../../chat/ChatMessage/ReplyMessageBox/ReplyMessageBox" -import { CustomFile } from "../../file-upload/FileDrop" +import { CustomFile, FileDrop } from "../../file-upload/FileDrop" import { FileListItem } from "../../file-upload/FileListItem" import { useParams } from "react-router-dom" import { Message } from "../../../../../../types/Messaging/Message" @@ -32,7 +32,7 @@ export const ThreadMessages = ({ threadMessage }: { threadMessage: Message }) => setSelectedMessage(null) } - const { fileInputRef, files, removeFile, uploadFiles, addFile, fileUploadProgress } = useFileUpload(threadID ?? '') + const { fileInputRef, files, setFiles, removeFile, uploadFiles, addFile, fileUploadProgress } = useFileUpload(threadID ?? '') const { sendMessage, loading } = useSendMessage(threadID ?? '', files.length, uploadFiles, handleCancelReply, selectedMessage) @@ -70,34 +70,42 @@ export const ThreadMessages = ({ threadMessage }: { threadMessage: Message }) => return ( - - - {!isUserInChannel && } - {isUserInChannel && } - />} + />} + ) } \ No newline at end of file diff --git a/raven/ai/ai.py b/raven/ai/ai.py index 787e57577..1398ad648 100644 --- a/raven/ai/ai.py +++ b/raven/ai/ai.py @@ -1,4 +1,5 @@ import frappe +from frappe.utils import get_files_path from raven.ai.handler import stream_response from raven.ai.openai_client import get_open_ai_client @@ -23,21 +24,52 @@ def handle_bot_dm(message, bot): text="Sorry, I don't support polls yet. Please send a text message or file.", ) return - ai_thread = client.beta.threads.create( - messages=[ - { - "role": "user", - "content": message.content, - "metadata": {"user": message.owner, "message": message.name}, - } - ], - metadata={ - "bot": bot.name, - "channel": message.channel_id, - "user": message.owner, - "message": message.name, - }, - ) + + if message.message_type == "File" or message.message_type == "Image": + # Upload the file to OpenAI + file_doc = frappe.get_doc("File", {"file_url": message.file}) + file_path = file_doc.get_full_path() + + file = client.files.create(file=open(file_path, "rb"), purpose="assistants") + + ai_thread = client.beta.threads.create( + messages=[ + { + "role": "user", + "content": "Uploaded a file", + "metadata": {"user": message.owner, "message": message.name}, + "attachments": [ + { + "file_id": file.id, + "tools": [{"type": "file_search"}], + } + ], + } + ], + metadata={ + "bot": bot.name, + "channel": message.channel_id, + "user": message.owner, + "message": message.name, + }, + ) + + else: + ai_thread = client.beta.threads.create( + messages=[ + { + "role": "user", + "content": message.content, + "metadata": {"user": message.owner, "message": message.name}, + } + ], + metadata={ + "bot": bot.name, + "channel": message.channel_id, + "user": message.owner, + "message": message.name, + }, + ) thread_channel = frappe.get_doc( { @@ -69,12 +101,29 @@ def handle_ai_thread_message(message, channel): client = get_open_ai_client() - client.beta.threads.messages.create( - thread_id=channel.openai_thread_id, - role="user", - content=message.content, - metadata={"user": message.owner, "message": message.name}, - ) + if message.message_type == "File" or message.message_type == "Image": + # Upload the file to OpenAI + file_doc = frappe.get_doc("File", {"file_url": message.file}) + file_path = file_doc.get_full_path() + + file = client.files.create(file=open(file_path, "rb"), purpose="assistants") + + client.beta.threads.messages.create( + thread_id=channel.openai_thread_id, + role="user", + content="Uploaded a file", + metadata={"user": message.owner, "message": message.name}, + attachments=[{"file_id": file.id, "tools": [{"type": "file_search"}]}], + ) + + else: + + client.beta.threads.messages.create( + thread_id=channel.openai_thread_id, + role="user", + content=message.content, + metadata={"user": message.owner, "message": message.name}, + ) bot = frappe.get_doc("Raven Bot", channel.thread_bot) diff --git a/raven/raven_messaging/doctype/raven_message/raven_message.py b/raven/raven_messaging/doctype/raven_message/raven_message.py index 8ecc94505..2bee2a7d7 100644 --- a/raven/raven_messaging/doctype/raven_message/raven_message.py +++ b/raven/raven_messaging/doctype/raven_message/raven_message.py @@ -118,10 +118,10 @@ def before_insert(self): } def after_insert(self): - # TODO: Enqueue this self.publish_unread_count_event() - self.handle_ai_message() + if self.message_type == "Text": + self.handle_ai_message() def handle_ai_message(self): @@ -142,11 +142,14 @@ def handle_ai_message(self): if is_ai_thread and channel_doc.openai_thread_id: - handle_ai_thread_message(self, channel_doc) - # frappe.enqueue(method=handle_ai_thread_message, - # message=self, - # channel=channel_doc, - # job_name="handle_ai_thread_message") + # handle_ai_thread_message(self, channel_doc) + frappe.enqueue( + method=handle_ai_thread_message, + message=self, + channel=channel_doc, + at_front=True, + job_name="handle_ai_thread_message", + ) return @@ -554,10 +557,15 @@ def on_update(self): after_commit=after_commit, ) # track the visit of the user to the channel if a new message is created - frappe.enqueue(method=track_channel_visit, channel_id=self.channel_id, user=self.owner) + track_channel_visit(channel_id=self.channel_id, user=self.owner) + # frappe.enqueue(method=track_channel_visit, channel_id=self.channel_id, user=self.owner) self.send_push_notification() + if self.message_type == "File" or self.message_type == "Image": + if self.file: + self.handle_ai_message() + def on_trash(self): # delete all the reactions for the message frappe.db.delete("Raven Message Reaction", {"message": self.name}) From 714441e1f7e152ca27b8ec0820f6a1a09735bc1d Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Wed, 11 Sep 2024 23:07:36 +0530 Subject: [PATCH 07/74] feat: sync bots with OpenAI assistants --- frontend/src/types/RavenBot/RavenBot.ts | 10 ++- raven/ai/ai.py | 62 ++++++++++++---- raven/ai/handler.py | 3 +- .../doctype/raven_bot/raven_bot.json | 31 ++++++-- .../raven_bot/doctype/raven_bot/raven_bot.py | 74 +++++++++++++++++++ 5 files changed, 155 insertions(+), 25 deletions(-) diff --git a/frontend/src/types/RavenBot/RavenBot.ts b/frontend/src/types/RavenBot/RavenBot.ts index c091b58cb..2090389af 100644 --- a/frontend/src/types/RavenBot/RavenBot.ts +++ b/frontend/src/types/RavenBot/RavenBot.ts @@ -1,7 +1,7 @@ export interface RavenBot{ - name: string creation: string + name: string modified: string owner: string modified_by: string @@ -28,4 +28,12 @@ export interface RavenBot{ openai_assistant_id?: string /** Allow Bot to Write Documents : Check */ allow_bot_to_write_documents?: 0 | 1 + /** Enable File Search : Check - Enable this if you want the bot to be able to read files and scan them. + +File search enables the assistant with knowledge from files that you upload. Once a file is uploaded, the assistant automatically decides when to retrieve content based on user requests. */ + enable_file_search?: 0 | 1 + /** Instruction : Long Text - You can use Jinja variables here to customize the instruction to the bot at run time if dynamic instructions are enabled. */ + instruction?: string + /** Dynamic Instructions : Check - Dynamic Instructions allow you to embed Jinja tags in your instruction to the bot. Hence the instruction would be different based on the user who is calling the bot or the data in your system. These instructions are computed every time the bot is called. Check this if you want to embed things like Employee ID, Company Name etc in your instructions dynamically */ + dynamic_instructions?: 0 | 1 } \ No newline at end of file diff --git a/raven/ai/ai.py b/raven/ai/ai.py index 1398ad648..bdf3ea34d 100644 --- a/raven/ai/ai.py +++ b/raven/ai/ai.py @@ -14,8 +14,6 @@ def handle_bot_dm(message, bot): client = get_open_ai_client() - # TODO: Handle various message types - # If the message is a poll, send a message to the user that we don't support polls for AI yet if message.message_type == "Poll": @@ -25,18 +23,18 @@ def handle_bot_dm(message, bot): ) return - if message.message_type == "File" or message.message_type == "Image": - # Upload the file to OpenAI - file_doc = frappe.get_doc("File", {"file_url": message.file}) - file_path = file_doc.get_full_path() + if message.message_type in ["File", "Image"]: - file = client.files.create(file=open(file_path, "rb"), purpose="assistants") + if message.message_type == "File" and not check_if_bot_has_file_search(bot, message.channel_id): + return + # Upload the file to OpenAI + file = create_file_in_openai(message.file, message.message_type, client) ai_thread = client.beta.threads.create( messages=[ { "role": "user", - "content": "Uploaded a file", + "content": "Uploaded a file" if message.message_type == "File" else "Uploaded an image", "metadata": {"user": message.owner, "message": message.name}, "attachments": [ { @@ -101,17 +99,19 @@ def handle_ai_thread_message(message, channel): client = get_open_ai_client() - if message.message_type == "File" or message.message_type == "Image": - # Upload the file to OpenAI - file_doc = frappe.get_doc("File", {"file_url": message.file}) - file_path = file_doc.get_full_path() + bot = frappe.get_doc("Raven Bot", channel.thread_bot) + + if message.message_type in ["File", "Image"]: - file = client.files.create(file=open(file_path, "rb"), purpose="assistants") + if message.message_type == "File" and not check_if_bot_has_file_search(bot, channel.name): + return + # Upload the file to OpenAI + file = create_file_in_openai(message.file, message.message_type, client) client.beta.threads.messages.create( thread_id=channel.openai_thread_id, role="user", - content="Uploaded a file", + content="Uploaded a file" if message.message_type == "File" else "Uploaded an image", metadata={"user": message.owner, "message": message.name}, attachments=[{"file_id": file.id, "tools": [{"type": "file_search"}]}], ) @@ -125,6 +125,36 @@ def handle_ai_thread_message(message, channel): metadata={"user": message.owner, "message": message.name}, ) - bot = frappe.get_doc("Raven Bot", channel.thread_bot) - stream_response(ai_thread_id=channel.openai_thread_id, bot=bot, channel_id=channel.name) + + +def check_if_bot_has_file_search(bot, channel_id): + """ + Checks of bot has file search. If not, send a message to the user. If yes, return True + """ + + if not bot.enable_file_search: + bot.send_message( + channel_id=channel_id, + text="Sorry, your bot does not support file search. Please enable it and try again.", + ) + return False + + return True + + +def create_file_in_openai(file_url: str, message_type: str, client): + """ + Function to create a file in OpenAI + + We need to upload the file to OpenAI and return the file ID + """ + + file_doc = frappe.get_doc("File", {"file_url": file_url}) + file_path = file_doc.get_full_path() + + file = client.files.create( + file=open(file_path, "rb"), purpose="assistants" if message_type == "File" else "vision" + ) + + return file diff --git a/raven/ai/handler.py b/raven/ai/handler.py index 3f6252c72..05074ba1f 100644 --- a/raven/ai/handler.py +++ b/raven/ai/handler.py @@ -101,7 +101,8 @@ def submit_tool_outputs(self, tool_outputs, run_id): def get_instructions(bot): - if not bot.instruction: + # If no instruction is set, or dynamic instruction is disabled, we return None + if not bot.instruction or not bot.dynamic_instructions: return None vars = get_variables_for_instructions(bot) diff --git a/raven/raven_bot/doctype/raven_bot/raven_bot.json b/raven/raven_bot/doctype/raven_bot/raven_bot.json index 80bffa41b..5c78c1119 100644 --- a/raven/raven_bot/doctype/raven_bot/raven_bot.json +++ b/raven/raven_bot/doctype/raven_bot/raven_bot.json @@ -16,13 +16,15 @@ "is_standard", "module", "ai_tab", - "ai_section", "is_ai_bot", + "ai_section", "openai_assistant_id", "column_break_khmi", "allow_bot_to_write_documents", + "enable_file_search", "section_break_lwkx", - "instruction" + "instruction", + "dynamic_instructions" ], "fields": [ { @@ -73,6 +75,7 @@ "fieldtype": "Column Break" }, { + "depends_on": "eval: doc.is_ai_bot", "fieldname": "ai_section", "fieldtype": "Section Break" }, @@ -83,11 +86,10 @@ "label": "Is AI Bot?" }, { - "depends_on": "eval: doc.is_ai_bot;", + "depends_on": "eval: doc.is_ai_bot", "fieldname": "openai_assistant_id", "fieldtype": "Data", - "label": "OpenAI Assistant ID", - "mandatory_depends_on": "eval: doc.is_ai_bot;" + "label": "OpenAI Assistant ID" }, { "fieldname": "column_break_khmi", @@ -109,16 +111,31 @@ "fieldtype": "Section Break" }, { - "description": "You can use Jinja variables here to customize the instruction to the bot at run time.", + "description": "You can use Jinja variables here to customize the instruction to the bot at run time if dynamic instructions are enabled.", "fieldname": "instruction", "fieldtype": "Long Text", "label": "Instruction" + }, + { + "default": "0", + "description": "Enable this if you want the bot to be able to read files and scan them.\n\nFile search enables the assistant with knowledge from files that you upload. Once a file is uploaded, the assistant automatically decides when to retrieve content based on user requests.", + "documentation_url": "https://platform.openai.com/docs/assistants", + "fieldname": "enable_file_search", + "fieldtype": "Check", + "label": "Enable File Search" + }, + { + "default": "0", + "description": "Dynamic Instructions allow you to embed Jinja tags in your instruction to the bot. Hence the instruction would be different based on the user who is calling the bot or the data in your system. These instructions are computed every time the bot is called. Check this if you want to embed things like Employee ID, Company Name etc in your instructions dynamically", + "fieldname": "dynamic_instructions", + "fieldtype": "Check", + "label": "Dynamic Instructions" } ], "image_field": "image", "index_web_pages_for_search": 1, "links": [], - "modified": "2024-08-23 19:59:02.021835", + "modified": "2024-09-11 21:27:45.772169", "modified_by": "Administrator", "module": "Raven Bot", "name": "Raven Bot", diff --git a/raven/raven_bot/doctype/raven_bot/raven_bot.py b/raven/raven_bot/doctype/raven_bot/raven_bot.py index 932b17b93..eff27c5b7 100644 --- a/raven/raven_bot/doctype/raven_bot/raven_bot.py +++ b/raven/raven_bot/doctype/raven_bot/raven_bot.py @@ -4,6 +4,7 @@ import frappe from frappe.model.document import Document +from raven.ai.openai_client import get_open_ai_client from raven.utils import get_raven_user @@ -19,6 +20,8 @@ class RavenBot(Document): allow_bot_to_write_documents: DF.Check bot_name: DF.Data description: DF.SmallText | None + dynamic_instructions: DF.Check + enable_file_search: DF.Check image: DF.AttachImage | None instruction: DF.LongText | None is_ai_bot: DF.Check @@ -55,6 +58,77 @@ def on_update(self): self.db_set("raven_user", raven_user.name) + if self.is_ai_bot: + if not self.openai_assistant_id: + self.create_openai_assistant() + else: + self.update_openai_assistant() + + def before_insert(self): + if self.is_ai_bot and not self.openai_assistant_id: + self.create_openai_assistant() + + def before_delete(self): + if self.raven_user: + frappe.db.set_value("Raven User", self.raven_user, "bot", None) + frappe.delete_doc("Raven User", self.raven_user) + + def on_trash(self): + if self.openai_assistant_id: + self.delete_openai_assistant() + + def create_openai_assistant(self): + # Create an OpenAI Assistant for the bot + client = get_open_ai_client() + + assistant = client.beta.assistants.create( + instructions=self.instruction, + model="gpt-4o", + name=self.bot_name, + description=self.description or "", + tools=self.get_tools_for_assistant(), + ) + + self.db_set("openai_assistant_id", assistant.id) + + def update_openai_assistant(self): + # Update the OpenAI Assistant for the bot + + client = get_open_ai_client() + + assistant = client.beta.assistants.update( + self.openai_assistant_id, + instructions=self.instruction, + name=self.bot_name, + description=self.description or "", + tools=self.get_tools_for_assistant(), + model="gpt-4o", + ) + + def get_tools_for_assistant(self): + tools = [] + + if self.enable_file_search: + tools.append( + { + "type": "file_search", + } + ) + + return tools + + def delete_openai_assistant(self): + # Delete the OpenAI Assistant for the bot + try: + client = get_open_ai_client() + client.beta.assistants.delete(self.openai_assistant_id) + except Exception: + frappe.log_error( + f"Error deleting OpenAI Assistant {self.openai_assistant_id} for bot {self.name}" + ) + + # Raven Bot Methods + def is_member(self, channel_id: str) -> None | str: """ Check if the bot is a member of the channel From af740d8accb19f135e8a5c9e914c18d5fee784f0 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Fri, 13 Sep 2024 18:58:11 +0530 Subject: [PATCH 08/74] feat: OpenAI settings page in Raven --- frontend/src/App.tsx | 13 +- .../feature/userSettings/SettingsSidebar.tsx | 14 +- .../userSettings/UserProfile/UserProfile.tsx | 33 ++-- .../feature/userSettings/Users/AddUsers.tsx | 33 ++-- .../feature/userSettings/Users/UsersTable.tsx | 2 +- .../components/layout/Loaders/TableLoader.tsx | 8 +- .../layout/Settings/PageContainer.tsx | 9 + .../Settings/SettingsContentContainer.tsx | 9 + .../layout/Settings/SettingsPageHeader.tsx | 22 +++ frontend/src/components/layout/Stack.tsx | 13 ++ frontend/src/pages/settings/AI/BotList.tsx | 103 ++++++++++++ .../src/pages/settings/AI/FunctionList.tsx | 89 ++++++++++ .../settings/AI/InstructionTemplateList.tsx | 71 ++++++++ .../src/pages/settings/AI/OpenAISettings.tsx | 159 ++++++++++++++++++ .../pages/settings/AI/SavedPromptsList.tsx | 72 ++++++++ .../pages/settings/Integrations/FrappeHR.tsx | 25 +-- .../src/types/RavenAI/RavenBotAIPrompt.ts | 19 +++ .../RavenAI/RavenBotInstructionTemplate.ts | 19 +++ .../doctype/raven_bot_ai_prompt/__init__.py | 0 .../raven_bot_ai_prompt.js | 8 + .../raven_bot_ai_prompt.json | 63 +++++++ .../raven_bot_ai_prompt.py | 22 +++ .../test_raven_bot_ai_prompt.py | 9 + .../__init__.py | 0 .../raven_bot_instruction_template.js | 8 + .../raven_bot_instruction_template.json | 65 +++++++ .../raven_bot_instruction_template.py | 22 +++ .../test_raven_bot_instruction_template.py | 9 + 28 files changed, 865 insertions(+), 54 deletions(-) create mode 100644 frontend/src/components/layout/Settings/PageContainer.tsx create mode 100644 frontend/src/components/layout/Settings/SettingsContentContainer.tsx create mode 100644 frontend/src/components/layout/Settings/SettingsPageHeader.tsx create mode 100644 frontend/src/components/layout/Stack.tsx create mode 100644 frontend/src/pages/settings/AI/BotList.tsx create mode 100644 frontend/src/pages/settings/AI/FunctionList.tsx create mode 100644 frontend/src/pages/settings/AI/InstructionTemplateList.tsx create mode 100644 frontend/src/pages/settings/AI/OpenAISettings.tsx create mode 100644 frontend/src/pages/settings/AI/SavedPromptsList.tsx create mode 100644 frontend/src/types/RavenAI/RavenBotAIPrompt.ts create mode 100644 frontend/src/types/RavenAI/RavenBotInstructionTemplate.ts create mode 100644 raven/raven_ai/doctype/raven_bot_ai_prompt/__init__.py create mode 100644 raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.js create mode 100644 raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json create mode 100644 raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.py create mode 100644 raven/raven_ai/doctype/raven_bot_ai_prompt/test_raven_bot_ai_prompt.py create mode 100644 raven/raven_ai/doctype/raven_bot_instruction_template/__init__.py create mode 100644 raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.js create mode 100644 raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json create mode 100644 raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.py create mode 100644 raven/raven_ai/doctype/raven_bot_instruction_template/test_raven_bot_instruction_template.py diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fd37bb9bc..189f4ef4e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,7 +9,6 @@ import { ThemeProvider } from './ThemeProvider' import { Toaster } from 'sonner' import { useStickyState } from './hooks/useStickyState' import MobileTabsPage from './pages/MobileTabsPage' -import { UserProfile } from './components/feature/userSettings/UserProfile/UserProfile' const router = createBrowserRouter( @@ -28,11 +27,15 @@ const router = createBrowserRouter( import('./components/feature/saved-messages/SavedMessages')} /> import('./pages/settings/Settings')}> - } /> - } /> + import('./components/feature/userSettings/UserProfile/UserProfile')} /> + import('./components/feature/userSettings/UserProfile/UserProfile')} /> import('./components/feature/userSettings/Users/AddUsers')} /> - import('./pages/settings/Integrations/FrappeHR')} /> - {/* import('./components/feature/userSettings/Bots')} /> */} + import('./pages/settings/Integrations/FrappeHR')} /> + import('./pages/settings/AI/BotList')} /> + import('./pages/settings/AI/FunctionList')} /> + import('./pages/settings/AI/InstructionTemplateList')} /> + import('./pages/settings/AI/SavedPromptsList')} /> + import('./pages/settings/AI/OpenAISettings')} /> import('@/pages/ChatSpace')}> import('./components/feature/threads/ThreadDrawer/ThreadDrawer')} /> diff --git a/frontend/src/components/feature/userSettings/SettingsSidebar.tsx b/frontend/src/components/feature/userSettings/SettingsSidebar.tsx index f62e0d43f..f6d0e933e 100644 --- a/frontend/src/components/feature/userSettings/SettingsSidebar.tsx +++ b/frontend/src/components/feature/userSettings/SettingsSidebar.tsx @@ -3,7 +3,7 @@ import { Box, Flex, Separator, Text } from '@radix-ui/themes' import clsx from 'clsx' import { PropsWithChildren, createElement } from 'react' import { IconType } from 'react-icons' -import { BiBuildings } from 'react-icons/bi' +import { BiBot, BiBuildings } from 'react-icons/bi' import { BsBoxes } from 'react-icons/bs' import { LuUserCircle2 } from 'react-icons/lu' import { NavLink } from 'react-router-dom' @@ -25,9 +25,17 @@ export const SettingsSidebar = () => { {/* */} + + + + + + + + {/* */} - + {/* */} {/* */} @@ -68,7 +76,7 @@ const SettingsSidebarItem = ({ title, to, end }: { title: string, to: string, en {({ isActive }) => { return ( - + {title} diff --git a/frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx b/frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx index 140ce4f94..d75334a60 100644 --- a/frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx +++ b/frontend/src/components/feature/userSettings/UserProfile/UserProfile.tsx @@ -13,6 +13,9 @@ import { GrPowerReset } from "react-icons/gr" import { BiSmile } from "react-icons/bi" import EmojiPicker from "@/components/common/EmojiPicker/EmojiPicker" import { useIsDesktop } from "@/hooks/useMediaQuery" +import PageContainer from "@/components/layout/Settings/PageContainer" +import SettingsPageHeader from "@/components/layout/Settings/SettingsPageHeader" +import SettingsContentContainer from "@/components/layout/Settings/SettingsContentContainer" type UserProfile = { full_name?: string, @@ -21,7 +24,7 @@ type UserProfile = { custom_status?: string } -export const UserProfile = () => { +const UserProfile = () => { const { myProfile, mutate } = useCurrentRavenUser() const methods = useForm({ @@ -59,22 +62,18 @@ export const UserProfile = () => { const isDesktop = useIsDesktop() return ( - - +
- - - - - Profile - Manage your Raven profile - - - + } + /> @@ -178,10 +177,12 @@ export const UserProfile = () => { - +
-
+ ) -} \ No newline at end of file +} + +export const Component = UserProfile \ No newline at end of file diff --git a/frontend/src/components/feature/userSettings/Users/AddUsers.tsx b/frontend/src/components/feature/userSettings/Users/AddUsers.tsx index 6e450aa35..ea9318eb1 100644 --- a/frontend/src/components/feature/userSettings/Users/AddUsers.tsx +++ b/frontend/src/components/feature/userSettings/Users/AddUsers.tsx @@ -16,6 +16,9 @@ import { PageLengthSelector } from "../../pagination/PageLengthSelector" import { PageSelector } from "../../pagination/PageSelector" import { UsersTable } from "./UsersTable" import { isSystemManager } from "@/utils/roles" +import PageContainer from "@/components/layout/Settings/PageContainer" +import SettingsPageHeader from "@/components/layout/Settings/SettingsPageHeader" +import SettingsContentContainer from "@/components/layout/Settings/SettingsContentContainer" interface AddUsersResponse { failed_users: User[], @@ -82,20 +85,18 @@ const AddUsers = () => { const canAddRavenUsers = isSystemManager() return ( - - - - - Add users to Raven - Only System managers have the ability to add users; users you add will be given the "Raven User" role. - - - - - + + + + + Only System managers have the ability to add users; users you add will be given the "Raven User" role.} + actions={} + /> { {data && data.length !== 0 && } - - + + ) } diff --git a/frontend/src/components/feature/userSettings/Users/UsersTable.tsx b/frontend/src/components/feature/userSettings/Users/UsersTable.tsx index 1a91ba119..d6515948b 100644 --- a/frontend/src/components/feature/userSettings/Users/UsersTable.tsx +++ b/frontend/src/components/feature/userSettings/Users/UsersTable.tsx @@ -56,7 +56,7 @@ export const UsersTable = ({ data, selected, setSelected, defaultSelected }: Use } return ( - + setAllChecked(e.valueOf() ? true : false)} /> diff --git a/frontend/src/components/layout/Loaders/TableLoader.tsx b/frontend/src/components/layout/Loaders/TableLoader.tsx index 16c92cbf0..2b6ef29ed 100644 --- a/frontend/src/components/layout/Loaders/TableLoader.tsx +++ b/frontend/src/components/layout/Loaders/TableLoader.tsx @@ -1,5 +1,6 @@ import { Skeleton } from "@/components/common/Skeleton" import { Table } from "@radix-ui/themes" +import TableHeader from "@tiptap/extension-table-header" interface Props { @@ -11,7 +12,12 @@ interface Props { export const TableLoader = ({ rows = 10, columns = 5, color = "gray", ...props }: Props) => { return ( - + + + + {[...Array(columns)].map((e, i) => )} + + { [...Array(rows)].map((e, index) => diff --git a/frontend/src/components/layout/Settings/PageContainer.tsx b/frontend/src/components/layout/Settings/PageContainer.tsx new file mode 100644 index 000000000..f7599d61e --- /dev/null +++ b/frontend/src/components/layout/Settings/PageContainer.tsx @@ -0,0 +1,9 @@ +import { Flex, FlexProps } from '@radix-ui/themes' + +const PageContainer = (props: FlexProps) => { + return ( + + ) +} + +export default PageContainer \ No newline at end of file diff --git a/frontend/src/components/layout/Settings/SettingsContentContainer.tsx b/frontend/src/components/layout/Settings/SettingsContentContainer.tsx new file mode 100644 index 000000000..3e8a67e78 --- /dev/null +++ b/frontend/src/components/layout/Settings/SettingsContentContainer.tsx @@ -0,0 +1,9 @@ +import { Flex, FlexProps } from '@radix-ui/themes' + +const SettingsContentContainer = (props: FlexProps) => { + return ( + + ) +} + +export default SettingsContentContainer \ No newline at end of file diff --git a/frontend/src/components/layout/Settings/SettingsPageHeader.tsx b/frontend/src/components/layout/Settings/SettingsPageHeader.tsx new file mode 100644 index 000000000..6dc370253 --- /dev/null +++ b/frontend/src/components/layout/Settings/SettingsPageHeader.tsx @@ -0,0 +1,22 @@ +import { Flex, Text } from '@radix-ui/themes' +import React from 'react' + +type Props = { + title: React.ReactNode + description?: React.ReactNode + actions?: React.ReactNode +} + +const SettingsPageHeader = (props: Props) => { + return ( + + + {props.title} + {props.description && {props.description}} + + {props.actions} + + ) +} + +export default SettingsPageHeader \ No newline at end of file diff --git a/frontend/src/components/layout/Stack.tsx b/frontend/src/components/layout/Stack.tsx new file mode 100644 index 000000000..df9a73511 --- /dev/null +++ b/frontend/src/components/layout/Stack.tsx @@ -0,0 +1,13 @@ +import { Flex, FlexProps } from '@radix-ui/themes' + +export const HStack = (props: FlexProps) => { + return ( + + ) +} + +export const Stack = (props: FlexProps) => { + return ( + + ) +} \ No newline at end of file diff --git a/frontend/src/pages/settings/AI/BotList.tsx b/frontend/src/pages/settings/AI/BotList.tsx new file mode 100644 index 000000000..8d27e6d32 --- /dev/null +++ b/frontend/src/pages/settings/AI/BotList.tsx @@ -0,0 +1,103 @@ +import { UserAvatar } from '@/components/common/UserAvatar' +import { ErrorBanner } from '@/components/layout/AlertBanner' +import { TableLoader } from '@/components/layout/Loaders/TableLoader' +import PageContainer from '@/components/layout/Settings/PageContainer' +import SettingsContentContainer from '@/components/layout/Settings/SettingsContentContainer' +import SettingsPageHeader from '@/components/layout/Settings/SettingsPageHeader' +import { HStack, Stack } from '@/components/layout/Stack' +import { RavenBot } from '@/types/RavenBot/RavenBot' +import { Badge, Button, HoverCard, Table, Text } from '@radix-ui/themes' +import { useFrappeGetDocList } from 'frappe-react-sdk' +import { BiSolidCheckCircle, BiSolidXCircle } from 'react-icons/bi' +import { RiSparkling2Fill } from 'react-icons/ri' +import { Link } from 'react-router-dom' + +type Props = {} + +const BotList = (props: Props) => { + + const { data, isLoading, error } = useFrappeGetDocList("Raven Bot", { + fields: ["name", "bot_name", "is_ai_bot", "description", "image", "enable_file_search", "dynamic_instructions", "instruction", "allow_bot_to_write_documents"] + }) + return ( + + + + Create + } + /> + {isLoading && } + + {data && } + + + ) +} + +const BotTable = ({ bots }: { bots: RavenBot[] }) => { + return ( + + + + Name + Description + + + + {bots?.map((bot) => ( + + + + + + + {bot.bot_name} + + + {bot.is_ai_bot ? + + + AI + + + + + + + + + + : null} + + + + {bot.description ? bot.description : bot.instruction} + + + ))} + + + ) +} + +const BotFeatureRow = ({ enabled, label }: { enabled?: 0 | 1, label: string }) => { + return ( + + {enabled ? : } + {label} + + ) +} +export const Component = BotList \ No newline at end of file diff --git a/frontend/src/pages/settings/AI/FunctionList.tsx b/frontend/src/pages/settings/AI/FunctionList.tsx new file mode 100644 index 000000000..29ac14771 --- /dev/null +++ b/frontend/src/pages/settings/AI/FunctionList.tsx @@ -0,0 +1,89 @@ +import { ErrorBanner } from '@/components/layout/AlertBanner' +import { TableLoader } from '@/components/layout/Loaders/TableLoader' +import PageContainer from '@/components/layout/Settings/PageContainer' +import SettingsContentContainer from '@/components/layout/Settings/SettingsContentContainer' +import SettingsPageHeader from '@/components/layout/Settings/SettingsPageHeader' +import { HStack, Stack } from '@/components/layout/Stack' +import { RavenAIFunction } from '@/types/RavenAI/RavenAIFunction' +import { Badge, Button, HoverCard, Table, Text } from '@radix-ui/themes' +import { useFrappeGetDocList } from 'frappe-react-sdk' +import { Link } from 'react-router-dom' + +type Props = {} + +const FunctionList = (props: Props) => { + + const { data, isLoading, error } = useFrappeGetDocList("Raven AI Function", { + fields: ["name"] + }) + return ( + + + + Create + } + /> + {isLoading && } + + {data && } + + + ) +} + +const FunctionTable = ({ functions }: { functions: RavenAIFunction[] }) => { + return ( + + + + Name + {/* Description */} + + + + {functions?.map((f) => ( + + + + + {f.function_path} + + {/* {bot.is_ai_bot ? + + + AI + + + + + + + + + + : null} */} + + + {/* + {bot.description ? bot.description : bot.instruction} + */} + + ))} + + + ) +} + +export const Component = FunctionList \ No newline at end of file diff --git a/frontend/src/pages/settings/AI/InstructionTemplateList.tsx b/frontend/src/pages/settings/AI/InstructionTemplateList.tsx new file mode 100644 index 000000000..5d7a3cea5 --- /dev/null +++ b/frontend/src/pages/settings/AI/InstructionTemplateList.tsx @@ -0,0 +1,71 @@ +import { ErrorBanner } from '@/components/layout/AlertBanner' +import { TableLoader } from '@/components/layout/Loaders/TableLoader' +import PageContainer from '@/components/layout/Settings/PageContainer' +import SettingsContentContainer from '@/components/layout/Settings/SettingsContentContainer' +import SettingsPageHeader from '@/components/layout/Settings/SettingsPageHeader' +import { HStack, Stack } from '@/components/layout/Stack' +import { RavenBotInstructionTemplate } from '@/types/RavenAI/RavenBotInstructionTemplate' +import { Badge, Button, Table, Text } from '@radix-ui/themes' +import { useFrappeGetDocList } from 'frappe-react-sdk' +import { RiSparkling2Fill } from 'react-icons/ri' +import { Link } from 'react-router-dom' + +type Props = {} + +const InstructionTemplateList = (props: Props) => { + + const { data, isLoading, error } = useFrappeGetDocList("Raven Bot Instruction Template", { + fields: ["name", "template_name", "dynamic_instructions", "instruction"] + }) + return ( + + + + Create + } + /> + {isLoading && } + + {data && } + + + ) +} + +const InstructionTable = ({ data }: { data: RavenBotInstructionTemplate[] }) => { + return ( + + + + Name + Description + + + + {data?.map((d) => ( + + + + + {d.template_name} + + {d.dynamic_instructions ? + Dynamic + + : null} + + + + {d.instruction} + + + ))} + + + ) +} + +export const Component = InstructionTemplateList \ No newline at end of file diff --git a/frontend/src/pages/settings/AI/OpenAISettings.tsx b/frontend/src/pages/settings/AI/OpenAISettings.tsx new file mode 100644 index 000000000..ab5ad04bb --- /dev/null +++ b/frontend/src/pages/settings/AI/OpenAISettings.tsx @@ -0,0 +1,159 @@ +import { ErrorText, HelperText, Label } from '@/components/common/Form' +import { Loader } from '@/components/common/Loader' +import PageContainer from '@/components/layout/Settings/PageContainer' +import SettingsContentContainer from '@/components/layout/Settings/SettingsContentContainer' +import SettingsPageHeader from '@/components/layout/Settings/SettingsPageHeader' +import useRavenSettings from '@/hooks/fetchers/useRavenSettings' +import { RavenSettings } from '@/types/Raven/RavenSettings' +import { Box, Button, Checkbox, Flex, Separator, Text, TextField } from '@radix-ui/themes' +import { useFrappeUpdateDoc } from 'frappe-react-sdk' +import { useEffect } from 'react' +import { Controller, FormProvider, useForm } from 'react-hook-form' +import { toast } from 'sonner' + +const OpenAISettings = () => { + + const { ravenSettings, mutate } = useRavenSettings() + + const methods = useForm() + + const { handleSubmit, control, watch, reset, register, formState: { errors } } = methods + + useEffect(() => { + if (ravenSettings) { + reset(ravenSettings) + } + }, [ravenSettings]) + + const { updateDoc, loading: updatingDoc } = useFrappeUpdateDoc() + + const onSubmit = (data: RavenSettings) => { + toast.promise(updateDoc('Raven Settings', null, { + ...(ravenSettings ?? {}), + ...data + }).then(res => { + mutate(res, { + revalidate: false + }) + }), { + loading: 'Updating...', + success: () => { + return `Settings updated`; + }, + error: 'There was an error.', + }) + + } + + const isAIEnabled = watch('enable_ai_integration') + + return ( + + +
+ + + {updatingDoc && } + {updatingDoc ? "Saving" : "Save"} + } + /> + + + + + ( + field.onChange(v ? 1 : 0)} + /> + )} /> + + Enable AI Integration + + + + + {isAIEnabled ? + + + + {errors?.openai_organisation_id && {errors.openai_organisation_id?.message}} + + : null + } + + {isAIEnabled ? + + + + {errors?.openai_api_key && {errors.openai_api_key?.message}} + + : null + } + + {isAIEnabled ? + + + + {errors?.openai_project_id && {errors.openai_project_id?.message}} + + If not set, the integration will use the default project. + + + : null + } + +
+
+
+ ) +} + +export const Component = OpenAISettings \ No newline at end of file diff --git a/frontend/src/pages/settings/AI/SavedPromptsList.tsx b/frontend/src/pages/settings/AI/SavedPromptsList.tsx new file mode 100644 index 000000000..00d2fd032 --- /dev/null +++ b/frontend/src/pages/settings/AI/SavedPromptsList.tsx @@ -0,0 +1,72 @@ +import { ErrorBanner } from '@/components/layout/AlertBanner' +import { TableLoader } from '@/components/layout/Loaders/TableLoader' +import PageContainer from '@/components/layout/Settings/PageContainer' +import SettingsContentContainer from '@/components/layout/Settings/SettingsContentContainer' +import SettingsPageHeader from '@/components/layout/Settings/SettingsPageHeader' +import { HStack, Stack } from '@/components/layout/Stack' +import { RavenBotAIPrompt } from '@/types/RavenAI/RavenBotAIPrompt' +import { Badge, Button, Checkbox, Table, Text } from '@radix-ui/themes' +import { useFrappeGetDocList } from 'frappe-react-sdk' +import { Link } from 'react-router-dom' + +type Props = {} + +const SavedPromptList = (props: Props) => { + + const { data, isLoading, error } = useFrappeGetDocList("Raven Bot AI Prompt", { + fields: ["name", "prompt", "raven_bot", "is_global"] + }) + return ( + + + + Create + } + /> + {isLoading && } + + {data && } + + + ) +} + +const SavedPromptTable = ({ data }: { data: RavenBotAIPrompt[] }) => { + return ( + + + + Name + Bot + Is Global? + + + + {data?.map((d) => ( + + + + + {d.prompt} + + + + + + {d.raven_bot} + + + + + + + ))} + + + ) +} + +export const Component = SavedPromptList \ No newline at end of file diff --git a/frontend/src/pages/settings/Integrations/FrappeHR.tsx b/frontend/src/pages/settings/Integrations/FrappeHR.tsx index e09445049..b3b5ffd1b 100644 --- a/frontend/src/pages/settings/Integrations/FrappeHR.tsx +++ b/frontend/src/pages/settings/Integrations/FrappeHR.tsx @@ -1,6 +1,9 @@ import { CustomCallout } from '@/components/common/Callouts/CustomCallout' import { HelperText, Label } from '@/components/common/Form' import { Loader } from '@/components/common/Loader' +import PageContainer from '@/components/layout/Settings/PageContainer' +import SettingsContentContainer from '@/components/layout/Settings/SettingsContentContainer' +import SettingsPageHeader from '@/components/layout/Settings/SettingsPageHeader' import useRavenSettings from '@/hooks/fetchers/useRavenSettings' import { RavenSettings } from '@/types/Raven/RavenSettings' import { Button, Checkbox, Flex, Select, Separator, Text } from '@radix-ui/themes' @@ -50,20 +53,18 @@ const FrappeHR = () => { const autoCreateDepartment = watch('auto_create_department_channel') return ( - +
- - - - Frappe HR - {/* Manage your Raven profile */} - - - + } + /> {!isHRInstalled && } rootProps={{ color: 'yellow', variant: 'surface' }} @@ -138,10 +139,10 @@ const FrappeHR = () => { If checked, users on Raven are notified if another user is on leave. - +
-
+ ) } diff --git a/frontend/src/types/RavenAI/RavenBotAIPrompt.ts b/frontend/src/types/RavenAI/RavenBotAIPrompt.ts new file mode 100644 index 000000000..94ac3c93e --- /dev/null +++ b/frontend/src/types/RavenAI/RavenBotAIPrompt.ts @@ -0,0 +1,19 @@ + +export interface RavenBotAIPrompt{ + creation: string + name: string + modified: string + owner: string + modified_by: string + docstatus: 0 | 1 | 2 + parent?: string + parentfield?: string + parenttype?: string + idx?: number + /** Prompt : Small Text */ + prompt: string + /** Raven Bot : Link - Raven Bot - If added, this prompt will only be shown when interacting with the bot */ + raven_bot?: string + /** Is Global : Check - If checked, this prompt will be available to all users on Raven */ + is_global?: 0 | 1 +} \ No newline at end of file diff --git a/frontend/src/types/RavenAI/RavenBotInstructionTemplate.ts b/frontend/src/types/RavenAI/RavenBotInstructionTemplate.ts new file mode 100644 index 000000000..7f0847c63 --- /dev/null +++ b/frontend/src/types/RavenAI/RavenBotInstructionTemplate.ts @@ -0,0 +1,19 @@ + +export interface RavenBotInstructionTemplate{ + creation: string + name: string + modified: string + owner: string + modified_by: string + docstatus: 0 | 1 | 2 + parent?: string + parentfield?: string + parenttype?: string + idx?: number + /** Template Name : Data */ + template_name: string + /** Dynamic Instructions : Check - Dynamic Instructions allow you to embed Jinja tags in your instruction to the bot. Hence the instruction would be different based on the user who is calling the bot or the data in your system. These instructions are computed every time the bot is called. Check this if you want to embed things like Employee ID, Company Name etc in your instructions dynamically */ + dynamic_instructions?: 0 | 1 + /** Instruction : Long Text - You can use Jinja variables here to customize the instruction to the bot at run time if dynamic instructions are enabled. */ + instruction: string +} \ No newline at end of file diff --git a/raven/raven_ai/doctype/raven_bot_ai_prompt/__init__.py b/raven/raven_ai/doctype/raven_bot_ai_prompt/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.js b/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.js new file mode 100644 index 000000000..e14098829 --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Raven Bot AI Prompt", { +// refresh(frm) { + +// }, +// }); diff --git a/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json b/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json new file mode 100644 index 000000000..eb234def3 --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.json @@ -0,0 +1,63 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2024-09-12 18:23:10.771302", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "prompt", + "raven_bot", + "is_global" + ], + "fields": [ + { + "fieldname": "prompt", + "fieldtype": "Small Text", + "in_list_view": 1, + "label": "Prompt", + "reqd": 1 + }, + { + "description": "If added, this prompt will only be shown when interacting with the bot", + "fieldname": "raven_bot", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Raven Bot", + "options": "Raven Bot" + }, + { + "default": "0", + "description": "If checked, this prompt will be available to all users on Raven", + "fieldname": "is_global", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Is Global" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-09-12 18:25:11.921825", + "modified_by": "Administrator", + "module": "Raven AI", + "name": "Raven Bot AI Prompt", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.py b/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.py new file mode 100644 index 000000000..28c137aac --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_ai_prompt/raven_bot_ai_prompt.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RavenBotAIPrompt(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + is_global: DF.Check + prompt: DF.SmallText + raven_bot: DF.Link | None + # end: auto-generated types + + pass diff --git a/raven/raven_ai/doctype/raven_bot_ai_prompt/test_raven_bot_ai_prompt.py b/raven/raven_ai/doctype/raven_bot_ai_prompt/test_raven_bot_ai_prompt.py new file mode 100644 index 000000000..c728120b9 --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_ai_prompt/test_raven_bot_ai_prompt.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestRavenBotAIPrompt(FrappeTestCase): + pass diff --git a/raven/raven_ai/doctype/raven_bot_instruction_template/__init__.py b/raven/raven_ai/doctype/raven_bot_instruction_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.js b/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.js new file mode 100644 index 000000000..7d87f88da --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.js @@ -0,0 +1,8 @@ +// Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Raven Bot Instruction Template", { +// refresh(frm) { + +// }, +// }); diff --git a/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json b/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json new file mode 100644 index 000000000..c2fafbd5f --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.json @@ -0,0 +1,65 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:template_name", + "creation": "2024-09-12 18:21:02.235237", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "template_name", + "dynamic_instructions", + "instruction" + ], + "fields": [ + { + "fieldname": "template_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Template Name", + "reqd": 1, + "unique": 1 + }, + { + "default": "0", + "description": "Dynamic Instructions allow you to embed Jinja tags in your instruction to the bot. Hence the instruction would be different based on the user who is calling the bot or the data in your system. These instructions are computed every time the bot is called. Check this if you want to embed things like Employee ID, Company Name etc in your instructions dynamically", + "fieldname": "dynamic_instructions", + "fieldtype": "Check", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Dynamic Instructions" + }, + { + "description": "You can use Jinja variables here to customize the instruction to the bot at run time if dynamic instructions are enabled.", + "fieldname": "instruction", + "fieldtype": "Long Text", + "in_list_view": 1, + "label": "Instruction", + "reqd": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2024-09-12 18:25:20.711534", + "modified_by": "Administrator", + "module": "Raven AI", + "name": "Raven Bot Instruction Template", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} \ No newline at end of file diff --git a/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.py b/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.py new file mode 100644 index 000000000..42b04e98a --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_instruction_template/raven_bot_instruction_template.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class RavenBotInstructionTemplate(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + dynamic_instructions: DF.Check + instruction: DF.LongText + template_name: DF.Data + # end: auto-generated types + + pass diff --git a/raven/raven_ai/doctype/raven_bot_instruction_template/test_raven_bot_instruction_template.py b/raven/raven_ai/doctype/raven_bot_instruction_template/test_raven_bot_instruction_template.py new file mode 100644 index 000000000..a3af026e5 --- /dev/null +++ b/raven/raven_ai/doctype/raven_bot_instruction_template/test_raven_bot_instruction_template.py @@ -0,0 +1,9 @@ +# Copyright (c) 2024, The Commit Company (Algocode Technologies Pvt. Ltd.) and Contributors +# See license.txt + +# import frappe +from frappe.tests.utils import FrappeTestCase + + +class TestRavenBotInstructionTemplate(FrappeTestCase): + pass From 7f3aa0c3cb22f469fbe293727031eebbf7b73590 Mon Sep 17 00:00:00 2001 From: Nikhil Kothari Date: Sat, 14 Sep 2024 18:17:52 +0530 Subject: [PATCH 09/74] feat: forms for bots, commands and prompts --- frontend/src/App.tsx | 28 +- frontend/src/components/common/Form.tsx | 4 +- .../common/LinkField/LinkFormField.tsx | 2 +- .../feature/CommandMenu/CommandMenu.tsx | 2 + .../feature/CommandMenu/SettingsList.tsx | 77 ++++ .../feature/channels/CreateChannelModal.tsx | 4 +- .../feature/settings/ai/InstructionField.tsx | 346 ++++++++++++++++++ .../settings/ai/InstructionTemplateForm.tsx | 38 ++ .../feature/settings/ai/SavedPromptForm.tsx | 67 ++++ .../settings/ai/bots/AIFeaturesBotForm.tsx | 89 +++++ .../feature/settings/ai/bots/BotForm.tsx | 48 +++ .../settings/ai/bots/BotFunctionsForm.tsx | 11 + .../settings/ai/bots/GeneralBotForm.tsx | 66 ++++ frontend/src/components/layout/Breadcrumb.tsx | 150 ++++++++ .../layout/Loaders/FullPageLoader.tsx | 5 +- .../layout/Settings/SettingsPageHeader.tsx | 34 +- frontend/src/pages/settings/AI/CreateBot.tsx | 57 +++ .../src/pages/settings/AI/CreateFunction.tsx | 52 +++ .../settings/AI/CreateInstructionTemplate.tsx | 52 +++ .../pages/settings/AI/CreateSavedPrompt.tsx | 54 +++ frontend/src/pages/settings/AI/ViewBot.tsx | 69 ++++ .../settings/AI/ViewInstructionTemplate.tsx | 67 ++++ .../src/pages/settings/AI/ViewSavedPrompt.tsx | 69 ++++ .../pages/settings/Integrations/FrappeHR.tsx | 2 +- .../src/types/RavenAI/RavenBotAIPrompt.ts | 2 + .../raven_bot_ai_prompt.json | 11 +- .../raven_bot_ai_prompt.py | 1 + .../raven_bot/doctype/raven_bot/raven_bot.py | 4 + 28 files changed, 1392 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/feature/CommandMenu/SettingsList.tsx create mode 100644 frontend/src/components/feature/settings/ai/InstructionField.tsx create mode 100644 frontend/src/components/feature/settings/ai/InstructionTemplateForm.tsx create mode 100644 frontend/src/components/feature/settings/ai/SavedPromptForm.tsx create mode 100644 frontend/src/components/feature/settings/ai/bots/AIFeaturesBotForm.tsx create mode 100644 frontend/src/components/feature/settings/ai/bots/BotForm.tsx create mode 100644 frontend/src/components/feature/settings/ai/bots/BotFunctionsForm.tsx create mode 100644 frontend/src/components/feature/settings/ai/bots/GeneralBotForm.tsx create mode 100644 frontend/src/components/layout/Breadcrumb.tsx create mode 100644 frontend/src/pages/settings/AI/CreateBot.tsx create mode 100644 frontend/src/pages/settings/AI/CreateFunction.tsx create mode 100644 frontend/src/pages/settings/AI/CreateInstructionTemplate.tsx create mode 100644 frontend/src/pages/settings/AI/CreateSavedPrompt.tsx create mode 100644 frontend/src/pages/settings/AI/ViewBot.tsx create mode 100644 frontend/src/pages/settings/AI/ViewInstructionTemplate.tsx create mode 100644 frontend/src/pages/settings/AI/ViewSavedPrompt.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 189f4ef4e..69c08e3ef 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -31,10 +31,30 @@ const router = createBrowserRouter( import('./components/feature/userSettings/UserProfile/UserProfile')} /> import('./components/feature/userSettings/Users/AddUsers')} /> import('./pages/settings/Integrations/FrappeHR')} /> - import('./pages/settings/AI/BotList')} /> - import('./pages/settings/AI/FunctionList')} /> - import('./pages/settings/AI/InstructionTemplateList')} /> - import('./pages/settings/AI/SavedPromptsList')} /> + + import('./pages/settings/AI/BotList')} /> + import('./pages/settings/AI/CreateBot')} /> + import('./pages/settings/AI/ViewBot')} /> + + + + import('./pages/settings/AI/FunctionList')} /> + import('./pages/settings/AI/CreateFunction')} /> + + + + + import('./pages/settings/AI/InstructionTemplateList')} /> + import('./pages/settings/AI/CreateInstructionTemplate')} /> + import('./pages/settings/AI/ViewInstructionTemplate')} /> + + + + import('./pages/settings/AI/SavedPromptsList')} /> + import('./pages/settings/AI/CreateSavedPrompt')} /> + import('./pages/settings/AI/ViewSavedPrompt')} /> + + import('./pages/settings/AI/OpenAISettings')} /> import('@/pages/ChatSpace')}> diff --git a/frontend/src/components/common/Form.tsx b/frontend/src/components/common/Form.tsx index 37f56ef3a..466c2dae3 100644 --- a/frontend/src/components/common/Form.tsx +++ b/frontend/src/components/common/Form.tsx @@ -18,12 +18,12 @@ export const Label = ({ children, isRequired, ...props }: LabelProps) => { export const HelperText = (props: TextProps) => { return ( - + ) } export const ErrorText = (props: TextProps) => { return ( - + ) } \ No newline at end of file diff --git a/frontend/src/components/common/LinkField/LinkFormField.tsx b/frontend/src/components/common/LinkField/LinkFormField.tsx index 3d1fc386c..c1d50460d 100644 --- a/frontend/src/components/common/LinkField/LinkFormField.tsx +++ b/frontend/src/components/common/LinkField/LinkFormField.tsx @@ -3,7 +3,7 @@ import LinkField, { LinkFieldProps } from './LinkField' interface LinkFormFieldProps extends Omit { name: string, - rules: ControllerProps['rules'], + rules?: ControllerProps['rules'], disabled?: boolean } diff --git a/frontend/src/components/feature/CommandMenu/CommandMenu.tsx b/frontend/src/components/feature/CommandMenu/CommandMenu.tsx index e4b1a42e9..0ec56926b 100644 --- a/frontend/src/components/feature/CommandMenu/CommandMenu.tsx +++ b/frontend/src/components/feature/CommandMenu/CommandMenu.tsx @@ -11,6 +11,7 @@ import ArchivedChannelList from './ArchivedChannelList' import { atom, useAtom } from 'jotai' import { useIsDesktop } from '@/hooks/useMediaQuery' import { Drawer, DrawerContent } from '@/components/layout/Drawer' +import SettingsList from './SettingsList' export const commandMenuOpenAtom = atom(false) @@ -69,6 +70,7 @@ export const CommandList = () => { No results found. + {/* TODO: Make these commands work */} {/* diff --git a/frontend/src/components/feature/CommandMenu/SettingsList.tsx b/frontend/src/components/feature/CommandMenu/SettingsList.tsx new file mode 100644 index 000000000..2a27d8e9b --- /dev/null +++ b/frontend/src/components/feature/CommandMenu/SettingsList.tsx @@ -0,0 +1,77 @@ +import { Command } from 'cmdk' +import { useSetAtom } from 'jotai' +import { BiBot, BiFile, BiGroup, BiMessageSquareDots, BiUserCircle } from 'react-icons/bi' +import { useNavigate } from 'react-router-dom' +import { commandMenuOpenAtom } from './CommandMenu' +import { PiOpenAiLogo } from 'react-icons/pi' +import { LuFunctionSquare } from 'react-icons/lu' + +type Props = {} + +const ICON_SIZE = 16 + +const SettingsList = (props: Props) => { + + const navigate = useNavigate() + + const setOpen = useSetAtom(commandMenuOpenAtom) + + const onSelect = (value: string) => { + navigate(`/channel/settings/${value}`) + setOpen(false) + } + return ( + + + + Profile + + + + Users + + + + + + + + + + + + + + + HR + + + + + Bots + + + + + Functions + + + + + Instructions + + + + + Commands + + + + + OpenAI Settings + + + ) +} + +export default SettingsList \ No newline at end of file diff --git a/frontend/src/components/feature/channels/CreateChannelModal.tsx b/frontend/src/components/feature/channels/CreateChannelModal.tsx index 30734905a..3a91a3134 100644 --- a/frontend/src/components/feature/channels/CreateChannelModal.tsx +++ b/frontend/src/components/feature/channels/CreateChannelModal.tsx @@ -243,9 +243,9 @@ const CreateChannelContent = ({ updateChannelList, isOpen, setIsOpen }: { update )} /> {/* Added min height to avoid layout shift when two lines of text are shown */} - + {helperText} - +
diff --git a/frontend/src/components/feature/settings/ai/InstructionField.tsx b/frontend/src/components/feature/settings/ai/InstructionField.tsx new file mode 100644 index 000000000..b81b2cb2c --- /dev/null +++ b/frontend/src/components/feature/settings/ai/InstructionField.tsx @@ -0,0 +1,346 @@ +import { ErrorText, HelperText, Label } from '@/components/common/Form' +import { ErrorBanner } from '@/components/layout/AlertBanner' +import { HStack, Stack } from '@/components/layout/Stack' +import { RavenBotInstructionTemplate } from '@/types/RavenAI/RavenBotInstructionTemplate' +import { Badge, Box, Button, Checkbox, Code, Flex, Popover, RadioCards, Separator, Table, Text, TextArea, TextAreaProps, Tooltip } from '@radix-ui/themes' +import { useFrappeGetDocList } from 'frappe-react-sdk' +import { useState } from 'react' +import { Controller, useFormContext } from 'react-hook-form' +import { RiSparkling2Fill } from 'react-icons/ri' +import { toast } from 'sonner' + +type Props = { + allowUsingTemplate?: boolean, + instructionRequired?: boolean +} + +interface InstructionFieldForm { + instruction: string + dynamic_instructions: 0 | 1 +} +const InstructionField = ({ allowUsingTemplate, instructionRequired }: Props) => { + + const { watch, control } = useFormContext() + + const isDynamic = watch('dynamic_instructions') + + return ( + + + + + ( + field.onChange(v ? 1 : 0)} + /> + )} /> + + Dynamic Instructions + + + + Dynamic Instructions allow you to embed Jinja tags in your instruction to the bot. +

+ Instructions would be different based on the user who is calling the bot or the data in your system as they are computed every time the bot is called. +
+
+ + {isDynamic ? : } +
+ ) +} + +const variables = [ + { variable: 'first_name', description: 'The first name of the user' }, + { variable: 'full_name', description: 'The full name of the user' }, + { variable: 'email', description: 'The email of the user' }, + { variable: 'user_id', description: 'The ID of the user' }, + { variable: 'company', description: 'The default company in the system / company of the employee' }, + { variable: 'employee_id', description: 'The ID of the employee' }, + { variable: 'department', description: 'The department of the employee' }, +] + +const DynamicInstructionField = ({ allowUsingTemplate, instructionRequired }: Props) => { + + // const ref = useRef(null) + + // const { setValue } = useFormContext() + + // TODO: Make it smarter + // const onPaste = (e: React.ClipboardEvent) => { + + // /** When the user pastes, we need to check where the user is pasting the text. + // * + // * If the text has {{ }} then we need to remove it first. + // * + // * Then, we need to check if the user has already typed either {{ or }} in the text area based on the current cursor position. + // * According to the cursor position, we need to add the missing {{ or }} in the text area. + // * + // * + // */ + + // console.log(e) + + // let textAreaText = e.target.value + // let copiedText = e.clipboardData.getData('text/plain') + + // if (copiedText.includes('{{') || copiedText.includes('}}')) { + // copiedText = copiedText.replace('{{', '').replace('}}', '') + // } + + // // Add the copied text to the text area based on the cursor position + // const start = e.target.selectionStart + // const end = e.target.selectionEnd + + // // Check if there's a {{ before the cursor position - and the {{ should not be before any text + // // Since checking for {{ is difficult, we can check if there's a character before the cursor position + + // let hasCharacterBefore = false + + // for (let i = start - 1; i >= 0; i--) { + // console.log("text", textAreaText[i], start, textAreaText.length) + // if (textAreaText[i] !== ' ' && textAreaText[i] !== '\n' && textAreaText[i] !== '\t' && textAreaText[i] !== '\r') { + // hasCharacterBefore = true + // break + // } + + // if (textAreaText[i] === '{' && i > 0 && textAreaText[i - 1] === '{') { + // hasCharacterBefore = false + // break + // } + // } + + // if (start === 0) { + // hasCharacterBefore = true + // } + + // if (hasCharacterBefore) { + // copiedText = '{{ ' + copiedText + // } + + // let hasCharacterAfter = false + + // for (let i = end; i < textAreaText.length; i++) { + // if (textAreaText[i] !== ' ' && textAreaText[i] !== '\n' && textAreaText[i] !== '\t' && textAreaText[i] !== '\r') { + // hasCharacterAfter = true + // break + // } + // if (textAreaText[i] === '}' && i < textAreaText.length - 1 && textAreaText[i + 1] === '}') { + // hasCharacterAfter = false + // break + // } + // } + + // if (end === textAreaText.length) { + // hasCharacterAfter = true + // } + + // if (hasCharacterAfter) { + // copiedText = copiedText + ' }}' + // } + + // const newText = textAreaText.slice(0, start) + copiedText + textAreaText.slice(end) + + // e.preventDefault() + + // setValue('instruction', newText) + + // // Set the cursor to the end of the pasted text for better user experience + // setTimeout(() => { + // ref.current?.setSelectionRange(start + copiedText.length, start + copiedText.length) + // }, 50) + // } + + return + + + + Here are some variables you can use in your instruction. Simple copy by clicking on the variable. + + + + + Variable + Description + + + + {variables.map((v) => )} + + + +} + +const VariableRow = ({ variable, description }: { variable: string, description: string }) => { + return + + {description} + +} + +const VariableTooltip = ({ text }: { text: string }) => { + + const [tooltip, setTooltip] = useState('') + + + const copyText = (e: React.MouseEvent) => { + e.preventDefault() + window.navigator.clipboard.writeText("{{ " + text + " }}") + .then(() => { + setTooltip('Copied!') + setTimeout(() => { + setTooltip('') + }, 1000) + }) + .catch(() => { + toast.error('Failed to copy to clipboard') + }) + } + + + return { + if (o) { + setTooltip('Copy to clipboard') + } + }} + > + + + {text} + +} + +const StaticInstructionField = ({ allowUsingTemplate, instructionRequired, ...props }: TextAreaProps & { allowUsingTemplate?: boolean, instructionRequired?: boolean }) => { + + const { register, watch, formState: { errors } } = useFormContext() + + + const isDynamic = watch('dynamic_instructions') + + const placeholder = isDynamic ? "You are an assistant running on an ERP. The current user's name is {{ first_name }} and the current company is {{ company }}." : 'You are an assistant running on an ERP. You can answer questions about the company.' + + return + + + + {allowUsingTemplate && } + +