From 9caad331304a7e82231b137d8702982d3173c0a8 Mon Sep 17 00:00:00 2001 From: Aditya Patil Date: Sat, 22 Aug 2026 13:54:05 +0530 Subject: [PATCH 01/12] feat: AI settings section --- .../features/settings/SettingsDialog.tsx | 12 +- .../features/settings/panels/AISettings.tsx | 103 +++++- .../settings/panels/AdminSettingsForm.tsx | 2 +- .../features/settings/panels/Agents.tsx | 22 -- .../settings/panels/Agents/AgentAITab.tsx | 227 +++++++++++++ .../panels/Agents/AgentApiDocsTab.tsx | 94 ++++++ .../Agents/AgentDocumentProcessorsTab.tsx | 203 ++++++++++++ .../panels/Agents/AgentEditorView.tsx | 198 ++++++++++++ .../panels/Agents/AgentFileSourcesTab.tsx | 214 +++++++++++++ .../settings/panels/Agents/AgentForm.tsx | 85 +++++ .../panels/Agents/AgentFunctionsTab.tsx | 124 ++++++++ .../panels/Agents/AgentGeneralTab.tsx | 46 +++ .../settings/panels/Agents/AgentListView.tsx | 170 ++++++++++ .../panels/Agents/AgentModelFields.tsx | 179 +++++++++++ .../settings/panels/Agents/Agents.tsx | 20 ++ .../features/settings/panels/Commands.tsx | 22 -- .../panels/Commands/CommandEditorView.tsx | 168 ++++++++++ .../panels/Commands/CommandListView.tsx | 138 ++++++++ .../settings/panels/Commands/Commands.tsx | 20 ++ .../panels/Commands/SavedPromptForm.tsx | 50 +++ .../settings/panels/DocumentProcessors.tsx | 16 - .../DocumentProcessors/DocumentProcessors.tsx | 275 ++++++++++++++++ .../ProcessorTypeSelector.tsx | 99 ++++++ .../features/settings/panels/FileSources.tsx | 22 -- .../FileSources/FileSourceUploadDialog.tsx | 156 +++++++++ .../panels/FileSources/FileSources.tsx | 212 ++++++++++++ .../features/settings/panels/Functions.tsx | 16 - .../Functions/DoctypeVariableBuilder.tsx | 242 ++++++++++++++ .../Functions/DoctypeVariableDialogForm.tsx | 301 ++++++++++++++++++ .../panels/Functions/FunctionConstants.ts | 138 ++++++++ .../panels/Functions/FunctionDetailsTab.tsx | 255 +++++++++++++++ .../panels/Functions/FunctionEditorView.tsx | 170 ++++++++++ .../panels/Functions/FunctionForm.tsx | 54 ++++ .../panels/Functions/FunctionListView.tsx | 138 ++++++++ .../settings/panels/Functions/Functions.tsx | 20 ++ .../panels/Functions/VariableBuilder.tsx | 252 +++++++++++++++ .../panels/Functions/VariableDialog.tsx | 151 +++++++++ .../panels/Functions/variableFieldMapping.ts | 121 +++++++ .../features/settings/panels/Instructions.tsx | 22 -- .../Instructions/InstructionEditorView.tsx | 168 ++++++++++ .../Instructions/InstructionListView.tsx | 134 ++++++++ .../Instructions/InstructionTemplateForm.tsx | 22 ++ .../panels/Instructions/Instructions.tsx | 20 ++ .../settings/panels/RecordActionsMenu.tsx | 85 +++++ .../panels/ai/AINotEnabledCallout.tsx | 43 +++ .../panels/ai/GoogleAPINotEnabledCallout.tsx | 30 ++ .../settings/panels/ai/InstructionField.tsx | 259 +++++++++++++++ .../settings/panels/ai/fieldTypeBadge.tsx | 41 +++ apps/web/src/components/ui/sonner.tsx | 5 +- apps/web/src/hooks/useDoctypeMetaDocs.ts | 15 + apps/web/src/hooks/useSaveHotkey.ts | 8 + packages/types/RavenAI/RavenAIBotFiles.ts | 15 + packages/types/RavenAI/RavenAIFileSource.ts | 21 ++ packages/types/RavenAI/RavenAIFunction.ts | 2 +- packages/types/RavenBot/RavenBot.ts | 19 ++ 55 files changed, 5498 insertions(+), 146 deletions(-) delete mode 100644 apps/web/src/components/features/settings/panels/Agents.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentAITab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentApiDocsTab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentDocumentProcessorsTab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentEditorView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentFileSourcesTab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentForm.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentFunctionsTab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentGeneralTab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentListView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/AgentModelFields.tsx create mode 100644 apps/web/src/components/features/settings/panels/Agents/Agents.tsx delete mode 100644 apps/web/src/components/features/settings/panels/Commands.tsx create mode 100644 apps/web/src/components/features/settings/panels/Commands/CommandEditorView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Commands/CommandListView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Commands/Commands.tsx create mode 100644 apps/web/src/components/features/settings/panels/Commands/SavedPromptForm.tsx delete mode 100644 apps/web/src/components/features/settings/panels/DocumentProcessors.tsx create mode 100644 apps/web/src/components/features/settings/panels/DocumentProcessors/DocumentProcessors.tsx create mode 100644 apps/web/src/components/features/settings/panels/DocumentProcessors/ProcessorTypeSelector.tsx delete mode 100644 apps/web/src/components/features/settings/panels/FileSources.tsx create mode 100644 apps/web/src/components/features/settings/panels/FileSources/FileSourceUploadDialog.tsx create mode 100644 apps/web/src/components/features/settings/panels/FileSources/FileSources.tsx delete mode 100644 apps/web/src/components/features/settings/panels/Functions.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/DoctypeVariableBuilder.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/DoctypeVariableDialogForm.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/FunctionConstants.ts create mode 100644 apps/web/src/components/features/settings/panels/Functions/FunctionDetailsTab.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/FunctionEditorView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/FunctionForm.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/FunctionListView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/Functions.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/VariableBuilder.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/VariableDialog.tsx create mode 100644 apps/web/src/components/features/settings/panels/Functions/variableFieldMapping.ts delete mode 100644 apps/web/src/components/features/settings/panels/Instructions.tsx create mode 100644 apps/web/src/components/features/settings/panels/Instructions/InstructionEditorView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Instructions/InstructionListView.tsx create mode 100644 apps/web/src/components/features/settings/panels/Instructions/InstructionTemplateForm.tsx create mode 100644 apps/web/src/components/features/settings/panels/Instructions/Instructions.tsx create mode 100644 apps/web/src/components/features/settings/panels/RecordActionsMenu.tsx create mode 100644 apps/web/src/components/features/settings/panels/ai/AINotEnabledCallout.tsx create mode 100644 apps/web/src/components/features/settings/panels/ai/GoogleAPINotEnabledCallout.tsx create mode 100644 apps/web/src/components/features/settings/panels/ai/InstructionField.tsx create mode 100644 apps/web/src/components/features/settings/panels/ai/fieldTypeBadge.tsx create mode 100644 apps/web/src/hooks/useDoctypeMetaDocs.ts create mode 100644 apps/web/src/hooks/useSaveHotkey.ts create mode 100644 packages/types/RavenAI/RavenAIBotFiles.ts create mode 100644 packages/types/RavenAI/RavenAIFileSource.ts diff --git a/apps/web/src/components/features/settings/SettingsDialog.tsx b/apps/web/src/components/features/settings/SettingsDialog.tsx index 31cb34066..7a46ce647 100644 --- a/apps/web/src/components/features/settings/SettingsDialog.tsx +++ b/apps/web/src/components/features/settings/SettingsDialog.tsx @@ -38,12 +38,12 @@ const DocumentPreviewsPanel = lazy(() => import('./panels/DocumentPreviews').the const MessageActionsPanel = lazy(() => import('./panels/MessageActions').then((m) => ({ default: m.MessageActions }))); const ScheduledMessagesPanel = lazy(() => import('./panels/ScheduledMessages').then((m) => ({ default: m.ScheduledMessages }))); const WebhooksPanel = lazy(() => import('./panels/Webhooks').then((m) => ({ default: m.Webhooks }))); -const AgentsPanel = lazy(() => import('./panels/Agents').then((m) => ({ default: m.Agents }))); -const FunctionsPanel = lazy(() => import('./panels/Functions').then((m) => ({ default: m.Functions }))); -const FileSourcesPanel = lazy(() => import('./panels/FileSources').then((m) => ({ default: m.FileSources }))); -const InstructionsPanel = lazy(() => import('./panels/Instructions').then((m) => ({ default: m.Instructions }))); -const DocumentProcessorsPanel = lazy(() => import('./panels/DocumentProcessors').then((m) => ({ default: m.DocumentProcessors }))); -const CommandsPanel = lazy(() => import('./panels/Commands').then((m) => ({ default: m.Commands }))); +const AgentsPanel = lazy(() => import('./panels/Agents/Agents').then((m) => ({ default: m.Agents }))); +const FunctionsPanel = lazy(() => import('./panels/Functions/Functions').then((m) => ({ default: m.Functions }))); +const FileSourcesPanel = lazy(() => import('./panels/FileSources/FileSources').then((m) => ({ default: m.FileSources }))); +const InstructionsPanel = lazy(() => import('./panels/Instructions/Instructions').then((m) => ({ default: m.Instructions }))); +const DocumentProcessorsPanel = lazy(() => import('./panels/DocumentProcessors/DocumentProcessors').then((m) => ({ default: m.DocumentProcessors }))); +const CommandsPanel = lazy(() => import('./panels/Commands/Commands').then((m) => ({ default: m.Commands }))); const KeyboardShortcutsPanel = lazy(() => import('./panels/KeyboardShortcuts').then((m) => ({ default: m.KeyboardShortcuts }))); const SETTINGS_TAB_GROUPS: { id: string, label: string }[] = [ diff --git a/apps/web/src/components/features/settings/panels/AISettings.tsx b/apps/web/src/components/features/settings/panels/AISettings.tsx index 38e3539c0..68076ff4e 100644 --- a/apps/web/src/components/features/settings/panels/AISettings.tsx +++ b/apps/web/src/components/features/settings/panels/AISettings.tsx @@ -1,5 +1,10 @@ +import { useState } from "react" import { useFormContext, useWatch } from "react-hook-form" +import { useFrappeGetCall, useFrappePostCall } from "frappe-react-sdk" +import { toast } from "sonner" import { Separator } from "@components/ui/separator" +import { Alert, AlertDescription } from "@components/ui/alert" +import { Button } from "@components/ui/button" import { DataField, SelectFormField, SwitchFormField } from "@components/ui/form-elements" import { SelectItem } from "@components/ui/select" import { AdminSettingsForm } from "./AdminSettingsForm" @@ -18,6 +23,39 @@ const AISettingsFields = () => { const aiEnabled = useWatch({ control, name: "enable_ai_integration" }) const openaiEnabled = useWatch({ control, name: "enable_openai_services" }) const localEnabled = useWatch({ control, name: "enable_local_llm" }) + const localProvider = useWatch({ control, name: "local_llm_provider" }) + const localLLMUrl = useWatch({ control, name: "local_llm_api_url" }) + + const { data: openaiVersion } = useFrappeGetCall<{ message: string }>( + "raven.api.ai_features.get_open_ai_version", + undefined, + openaiEnabled ? undefined : null + ) + + const { call: testConnection, loading: testing } = useFrappePostCall<{ + message: { success: boolean; message: string; models?: { id: string }[] } + }>("raven.api.ai_features.test_llm_configuration") + + const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null) + + const handleTestConnection = async () => { + try { + const result = await testConnection({ + provider: "Local LLM", + api_url: localLLMUrl, + local_llm_provider: localProvider, + }) + setTestResult({ success: result.message.success, message: result.message.message }) + if (result.message.success) { + toast.success(_("Connection successful!")) + } else { + toast.error(result.message.message) + } + } catch { + toast.error(_("Failed to test connection")) + setTestResult({ success: false, message: _("Failed to test connection") }) + } + } return ( <> @@ -43,7 +81,10 @@ const AISettingsFields = () => { name="openai_organisation_id" label={_("OpenAI Organization ID")} isRequired - rules={{ required: _("Please add your OpenAI Organization ID") }} + rules={{ + required: _("Please add your OpenAI Organization ID"), + maxLength: { value: 140, message: _("ID cannot be more than 140 characters.") }, + }} inputProps={{ placeholder: "org-************************", autoComplete: "off" }} /> { name="openai_project_id" label={_("OpenAI Project ID")} formDescription={_("If not set, the integration uses the default project.")} + rules={{ maxLength: { value: 140, message: _("ID cannot be more than 140 characters.") } }} inputProps={{ placeholder: "proj_************************", autoComplete: "off" }} /> ) : null} + {openaiEnabled ? (openaiVersion && ( +

{_("OpenAI Python SDK Version:")} {openaiVersion.message}

+ )) : null} @@ -78,17 +123,46 @@ const AISettingsFields = () => { {_("LocalAI")} {_("OpenAI Compatible")} - - +
+
+ +
+ +
+ {localProvider === "OpenAI Compatible" ? ( + + ) : null} + {testResult && ( + + {testResult.message} + + )} + + + {localProvider === "LM Studio" && _("Make sure LM Studio is running with the server enabled on the specified URL.")} + {localProvider === "Ollama" && _("Make sure Ollama is running. Default URL is usually http://localhost:11434/v1")} + {localProvider === "LocalAI" && _("Make sure LocalAI is running on the specified URL.")} + {localProvider === "OpenAI Compatible" && _("Make sure your OpenAI compatible service is running on the specified URL and that you have provided a valid API key.")} + {!localProvider && _("Select a provider to see specific instructions.")} + + ) : null} @@ -97,10 +171,7 @@ const AISettingsFields = () => { ) } -/** - * AI Settings — configure AI providers (OpenAI / local LLM). Ported from v2's - * AISettings; the provider sections show only when AI integration is on. - */ +/** AI Settings — configure AI providers (OpenAI / local LLM); provider sections show only when AI integration is on. */ export const AISettings = () => ( hasRole("Raven Admin") || hasRole("System Manager") +export const isRavenSettingsAdmin = () => hasRole("Raven Admin") || hasRole("System Manager") /** * Shared scaffold for the admin-facing Raven Settings panels (AI, HR, Notifications). diff --git a/apps/web/src/components/features/settings/panels/Agents.tsx b/apps/web/src/components/features/settings/panels/Agents.tsx deleted file mode 100644 index 8eaf4a51f..000000000 --- a/apps/web/src/components/features/settings/panels/Agents.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { BotIcon } from "lucide-react" -import { PlaceholderPanel } from "./PlaceholderPanel" -import _ from "@lib/translate" - -/** AI → Agents. TODO: list + create AI agents. */ -export const Agents = () => ( - - {_("Create agents to run automations on Raven.")} -
- {_("Send reminders, document notifications and run AI assistants.")} - - } - /> -) - -export default Agents diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentAITab.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentAITab.tsx new file mode 100644 index 000000000..28d9cf8db --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentAITab.tsx @@ -0,0 +1,227 @@ +import { Controller, useFormContext, useWatch } from "react-hook-form" +import { InfoIcon } from "lucide-react" +import { Alert, AlertDescription } from "@components/ui/alert" +import { FormControl, FormDescription, FormField, FormItem, FormLabel } from "@components/ui/form" +import { DataField, SwitchFormField } from "@components/ui/form-elements" +import { Separator } from "@components/ui/separator" +import { Slider } from "@components/ui/slider" +import { Switch } from "@components/ui/switch" +import { Tooltip, TooltipContent, TooltipTrigger } from "@components/ui/tooltip" +import { useRavenSettings } from "@hooks/fetchers/useRavenSettings" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import { ModelProviderSelector, ModelSelector, ReasoningEffortSelector } from "./AgentModelFields" +import _ from "@lib/translate" + +/** AI tab of the Raven Bot editor. */ +const AgentAITab = () => { + const { control } = useFormContext() + const { ravenSettings } = useRavenSettings() + const openAIAssistantID = useWatch({ control, name: "openai_assistant_id" }) + const modelProvider = useWatch({ control, name: "model_provider" }) + const isAiBot = useWatch({ control, name: "is_ai_bot" }) + + const isLocalLLM = modelProvider === "Local LLM" + const isOpenAI = !modelProvider || modelProvider === "OpenAI" + + const hasOpenAI = ravenSettings?.enable_openai_services === 1 + const hasLocalLLM = ravenSettings?.enable_local_llm === 1 + + return ( +
+ {!isLocalLLM && openAIAssistantID && ( + + )} + + {isAiBot && !hasOpenAI && !hasLocalLLM ? ( + + + {_("No AI providers are configured. Please configure OpenAI or Local LLM in AI Settings.")} + + + ) : ( +
+ + +
+ )} + + {isOpenAI && } + + + + + + + + {isOpenAI && ( + <> +
+ + +
+ + + )} + + {isLocalLLM && ( + + + {_("Currently, code interpreter features are not available for Local LLM providers. These features require OpenAI's infrastructure.")} + + + )} + +
{_("Advanced")}
+ + + +
+ + +
+
+ ) +} + +type DocLinkedSwitchFormFieldProps = { + name: "enable_file_search" | "enable_code_interpreter" + label: string + formDescription: string + docUrl: string + docTitle: string +} + +/** SwitchFormField variant whose label carries an external-docs info link. */ +const DocLinkedSwitchFormField = ({ name, label, formDescription, docUrl, docTitle }: DocLinkedSwitchFormFieldProps) => { + const { control } = useFormContext() + + return ( + ( + +
+ + {label} + + + + + + + {docTitle} + + + {formDescription} +
+ + field.onChange(checked ? 1 : 0)} + /> + +
+ )} + /> + ) +} + +type NumberSliderFieldProps = { + name: "temperature" | "top_p" + label: string + min: number + max: number + formDescription: string +} + +/** Controller-wrapped Slider with a label row, live mono readout and helper text. */ +const NumberSliderField = ({ name, label, min, max, formDescription }: NumberSliderFieldProps) => { + const { control } = useFormContext() + + return ( + ( +
+
+ + {label}{" "} + {_("(Default: 1)")} + + + {(field.value ?? 1).toFixed(2)} + +
+ field.onChange(values[0])} + min={min} + max={max} + step={0.01} + aria-label={label} + /> +

{formDescription}

+
+ )} + /> + ) +} + +export default AgentAITab diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentApiDocsTab.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentApiDocsTab.tsx new file mode 100644 index 000000000..637994072 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentApiDocsTab.tsx @@ -0,0 +1,94 @@ +import { useFormContext } from "react-hook-form" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import _ from "@lib/translate" + +/** API Docs tab of the Raven Bot editor. */ +const AgentApiDocsTab = () => { + const { getValues } = useFormContext() + + const botID = getValues("name") + + const botVarName = botID.replace(/[^a-zA-Z0-9_]/g, "_") + + const codeSamples = { + sendMessage: `${botVarName} = frappe.get_doc("Raven Bot", "${botID}") + +# Send a message to a channel. Text can be in HTML format. +${botVarName}.send_message(channel_id="channel-name", text="This is a test message.")`, + + sendMessageInMarkdown: `${botVarName}.send_message( + channel_id="channel-name", + text="This is a test message.", + markdown=True + )`, + + sendMessageWithDocumentLink: `${botVarName}.send_message( + channel_id="channel-name", + text="This is a test message.", + link_doctype="DocType", + link_document="Document Name" + )`, + + sendDirectMessage: `${botVarName}.send_direct_message( + user_id="john.doe@example.com", + text="This is a test message." + )`, + } + + return ( +
+

+ {_("The following code samples show how to use the bot/agent in a Frappe app or Server Script.")} +

+ +
+

{_("Sending a message to a channel")}

+

+ {_("Bots can be used to send messages to channels with HTML formatted content.")} +

+ +
+ +
+

{_("Sending a message to a channel in markdown format")}

+

+ {_("You can send markdown formatted text to a channel by setting the")}{" "} + markdown{" "} + {_("parameter to True.")} +

+ +
+ +
+

{_("Sending a message with a document link")}

+

+ {_("You can send a message with a link to any document in the system by setting the")}{" "} + link_doctype{" "} + {_("and")}{" "} + link_document{" "} + {_("parameters.")} +

+ +
+ +
+

{_("Sending a direct message to a user")}

+

+ {_("You can send a direct message to a user by calling the")}{" "} + send_direct_message{" "} + {_("method and setting the user_id parameter. This method also accepts markdown and document link parameters.")} +

+ +
+
+ ) +} + +/** Monospace block for the un-translated Python samples. */ +const CodeBlock = ({ sample }: { sample: string }) => ( +
+        {sample}
+    
+) + +export default AgentApiDocsTab diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentDocumentProcessorsTab.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentDocumentProcessorsTab.tsx new file mode 100644 index 000000000..e47df3221 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentDocumentProcessorsTab.tsx @@ -0,0 +1,203 @@ +import { Controller, useFormContext, useFormState, useWatch } from "react-hook-form" +import { useFrappeGetCall } from "frappe-react-sdk" +import { useSetAtom } from "jotai" +import { CheckIcon, InfoIcon } from "lucide-react" +import { Alert, AlertDescription } from "@components/ui/alert" +import { Badge } from "@components/ui/badge" +import { FormMessage } from "@components/ui/form" +import { SwitchFormField } from "@components/ui/form-elements" +import { RadioGroup, RadioGroupItem } from "@components/ui/radio-group" +import { Separator } from "@components/ui/separator" +import { Skeleton } from "@components/ui/skeleton" +import { settingsDialogOpenTab } from "@components/features/settings/settingsDialogAtom" +import { useRavenSettings } from "@hooks/fetchers/useRavenSettings" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import type { ExistingProcessor } from "../DocumentProcessors/DocumentProcessors" +import _ from "@lib/translate" + +interface ExistingProcessorsResponse { + message: ExistingProcessor[] +} + +const getProcessorTypeBestFor = (processorType: string) => { + switch (processorType) { + case "OCR_PROCESSOR": + return ["General text extraction", "Multi-language documents", "Handwriting recognition"] + case "FORM_PARSER_PROCESSOR": + return ["Application forms", "Surveys", "Registration forms", "Structured data"] + case "BANK_STATEMENT_PROCESSOR": + return ["Financial analysis", "Loan processing", "Bank statement digitization"] + case "INVOICE_PROCESSOR": + return ["Invoice processing", "Accounts payable automation", "Tax document preparation", "Financial record keeping"] + case "EXPENSE_PROCESSOR": + return ["Expense reports", "Travel reimbursements", "Receipt digitization"] + default: + return [] + } +} + +/** Document Processors tab of the Raven Bot editor. */ +const AgentDocumentProcessorsTab = () => { + const { control } = useFormContext() + const formState = useFormState({ control, name: "google_document_processor_id" }) + const { ravenSettings } = useRavenSettings() + const setOpenTab = useSetAtom(settingsDialogOpenTab) + + const useDocumentParser = useWatch({ control, name: "use_google_document_parser" }) + const isGoogleApisEnabled = ravenSettings?.enable_google_apis + + const { + data: existingProcessors, + isLoading: loadingProcessors, + error: processorsError, + } = useFrappeGetCall( + "raven.ai.google_ai.get_list_of_processors", + undefined, + useDocumentParser && isGoogleApisEnabled ? undefined : null, + { revalidateOnFocus: false }, + ) + + if (!isGoogleApisEnabled) { + return ( + + + + {_("Document Processors require Google Cloud APIs to be enabled in your Raven settings.")} + + + ) + } + + const hasExistingProcessors = existingProcessors?.message && existingProcessors.message.length > 0 + + return ( +
+ + + {useDocumentParser ? ( + <> + +
+
+
{_("Document Processor Selection")}
+
+

+ {_("Choose an existing document processor for this bot. Processors can be shared across multiple bots.")} +

+ +
+
+ + {/* Loading state */} + {loadingProcessors && } + + {/* Error state */} + {processorsError && ( + + + + {_("Error fetching processors: {0}", [processorsError.message])} + + + )} + + {/* No processors available */} + {!loadingProcessors && !processorsError && !hasExistingProcessors && ( + + + + + {_( + "No document processors have been created yet. You need to create at least one processor before you can assign it to this bot." + )} + + + + + )} + + {/* Processor selection */} + {!loadingProcessors && hasExistingProcessors && ( +
+ ( + + {existingProcessors.message.map((processor) => ( + + ))} + + )} + /> + +

+ {_( + "This processor will be used to process any documents, images, or PDFs uploaded to the thread and send its results to the agent for better context." + )} +

+ + {formState.errors.google_document_processor_id && ( + + {formState.errors.google_document_processor_id.message} + + )} +
+ )} +
+ + ) : null} +
+ ) +} + +export default AgentDocumentProcessorsTab diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentEditorView.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentEditorView.tsx new file mode 100644 index 000000000..9702422d7 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentEditorView.tsx @@ -0,0 +1,198 @@ +import { useContext } from "react" +import { useFrappeCreateDoc, useFrappeGetDoc, useFrappeUpdateDoc, useSWRConfig, FrappeContext, type FrappeConfig, type FrappeDoc, type SWRResponse } from "frappe-react-sdk" +import { useForm } from "react-hook-form" +import { useNavigate } from "react-router" +import { useSetAtom } from "jotai" +import { toast } from "sonner" +import { ArrowLeftIcon, ExternalLinkIcon } from "lucide-react" +import { Badge } from "@components/ui/badge" +import { Button } from "@components/ui/button" +import ErrorBanner from "@components/ui/error-banner" +import { Form } from "@components/ui/form" +import { + SettingsPanelContent, + SettingsPanelHeader, + SettingsPanelTitle, +} from "@components/ui/settings-dialog" +import { Spinner } from "@components/ui/spinner" +import useSaveHotkey from "@hooks/useSaveHotkey" +import { settingsDialogOpenTab } from "@components/features/settings/settingsDialogAtom" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import AgentForm from "./AgentForm" +import RecordActionsMenu from "../RecordActionsMenu" +import _ from "@lib/translate" + +type Props = { + /** Set for detail/edit mode; absent for create mode. */ + id?: string + onBack: () => void + onSaved?: (id: string) => void + onDeleted?: () => void +} + +/** AI → Agents editor: create mode when no id, detail/edit mode otherwise. */ +const AgentEditorView = ({ id, onBack, onSaved, onDeleted }: Props) => { + if (id) { + return undefined)} /> + } + return undefined)} /> +} + +const AgentCreate = ({ onBack, onSaved }: { onBack: () => void; onSaved: (id: string) => void }) => { + const { createDoc, loading, error } = useFrappeCreateDoc() + const { mutate: globalMutate } = useSWRConfig() + const methods = useForm({ + defaultValues: { bot_name: "", description: "", is_ai_bot: 0, enable_file_search: 1, enable_code_interpreter: 1 }, + }) + const { handleSubmit } = methods + + const onSubmit = async (data: RavenBot) => { + const doc = await createDoc("Raven Bot", data) + await globalMutate("raven-bots") + onSaved(doc.name) + } + + useSaveHotkey(() => handleSubmit(onSubmit)()) + + return ( +
+ + + {loading && } + {loading ? _("Creating") : _("Create")} + + } + > + + + {_("Create an Agent")} + + + + {error && } + + +
+ + ) +} + +const AgentDetail = ({ id, onBack, onDeleted }: { id: string; onBack: () => void; onDeleted: () => void }) => { + const { data, isLoading, error, mutate } = useFrappeGetDoc("Raven Bot", id) + + return ( + <> + {error && ( + + + + )} + {isLoading && ( + + + + )} + {data && } + + ) +} + +const AgentDetailContent = ({ + id, data, mutate, onBack, onDeleted, +}: { + id: string + data: RavenBot + mutate: SWRResponse>["mutate"] + onBack: () => void + onDeleted: () => void +}) => { + const { updateDoc, loading, error } = useFrappeUpdateDoc() + const { mutate: globalMutate } = useSWRConfig() + const methods = useForm({ defaultValues: data }) + const { handleSubmit } = methods + const isDirty = Object.keys(methods.formState.dirtyFields).length > 0 + + const onSubmit = async (formData: RavenBot) => { + const doc = await updateDoc("Raven Bot", data.name, formData) + toast.success(_("Saved")) + methods.reset(doc) + mutate(doc, { revalidate: false }) + await globalMutate("raven-bots") + } + + useSaveHotkey(() => handleSubmit(onSubmit)()) + + return ( +
+ + + { + await globalMutate("raven-bots") + onDeleted() + }} + /> + + + + } + > + + + {data.bot_name} + {isDirty && {_("Not Saved")}} + + + + {error && } + + +
+ + ) +} + +/** Opens the direct-message chat with the bot, closing the settings dialog first. */ +const OpenChatButton = ({ bot }: { bot: RavenBot }) => { + const { call } = useContext(FrappeContext) as FrappeConfig + const navigate = useNavigate() + const setOpenTab = useSetAtom(settingsDialogOpenTab) + + const openChat = () => { + call.post("raven.api.raven_channel.create_direct_message_channel", { user_id: bot.raven_user }) + .then((res: { message: string }) => { + setOpenTab("") + navigate(`/dm-channel/${encodeURIComponent(res.message)}`) + }) + .catch(() => toast.error(_("Failed to create chat channel"))) + } + + return ( + + ) +} + +export default AgentEditorView diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentFileSourcesTab.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentFileSourcesTab.tsx new file mode 100644 index 000000000..c4f6fc027 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentFileSourcesTab.tsx @@ -0,0 +1,214 @@ +import { useMemo, useState } from "react" +import { useFieldArray, useFormContext } from "react-hook-form" +import { useFrappeGetDocList } from "frappe-react-sdk" +import { Trash2Icon } from "lucide-react" +import { Badge } from "@components/ui/badge" +import { Button } from "@components/ui/button" +import { Checkbox } from "@components/ui/checkbox" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@components/ui/dialog" +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@components/ui/table" +import ErrorBanner from "@components/ui/error-banner" +import { Skeleton } from "@components/ui/skeleton" +import FileSourceUploadDialog from "../FileSources/FileSourceUploadDialog" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import type { RavenAIFileSource } from "@raven/types/RavenAI/RavenAIFileSource" +import _ from "@lib/translate" + +/** Files tab of the Raven Bot editor. */ +const AgentFileSourcesTab = () => { + const { control } = useFormContext() + const { fields, append, remove } = useFieldArray({ control, name: "file_sources" }) + const [selectOpen, setSelectOpen] = useState(false) + + // One list fetch for all attached rows instead of a get_value call per row. + const ids = fields.map((field) => field.file).sort() + const { data: fileSources } = useFrappeGetDocList( + "Raven AI File Source", + { + fields: ["name", "file_name", "file_type", "file"], + filters: [["name", "in", ids]], + limit: 0, // frappe-react-sdk drops a falsy limit → no cap + }, + ids.length ? `agent-file-sources-${ids.join(",")}` : null, + { revalidateOnFocus: false }, + ) + const fileSourcesByName = useMemo( + () => new Map(fileSources?.map((fileSource) => [fileSource.name, fileSource] as const)), + [fileSources], + ) + + const addNew = (ids: string[]) => { + //@ts-expect-error - append accepts a partial row; the rest are server-set defaults + ids.forEach((id) => append({ file: id })) + setSelectOpen(false) + } + + return ( +
+
+

+ {_("Files like manuals, sheets etc can be added to the AI agent as instructions.")} +

+
+ append({ file: id })} + trigger={ + + } + /> + + + + + + + {_("Select Files")} + {_("Select files from the list below.")} + + field.file)} onAdd={addNew} /> + + +
+
+ + + + + {_("Name")} + {_("Type")} + + + + + {fields.map((field, index) => ( + remove(index)} /> + ))} + +
+
+ ) +} + +/** One attached file row — display values come from the parent's single list fetch. */ +const FileSourceRow = ({ file, onDelete }: { file?: RavenAIFileSource; onDelete: () => void }) => { + return ( + + + + {file?.file_name} + + + + {file?.file_type && ( + + {file.file_type} + + )} + + + + + + ) +} + +/** Pick existing Raven AI File Sources via checkboxes, then append them all at once. */ +const SelectExistingFiles = ({ existingFiles, onAdd }: { existingFiles: string[]; onAdd: (ids: string[]) => void }) => { + const [selectedFiles, setSelectedFiles] = useState([]) + + const { data, isLoading, error } = useFrappeGetDocList( + "Raven AI File Source", + { + fields: ["name", "file_name", "file_type", "file"], + }, + "agent-select-file-sources", + { revalidateOnFocus: false }, + ) + + const onSelect = (id: string) => { + setSelectedFiles((prev) => (prev.includes(id) ? prev.filter((fileID) => fileID !== id) : [...prev, id])) + } + + const availableFiles = data?.filter((fileSource) => !existingFiles.includes(fileSource.name)) ?? [] + + return ( +
+ {isLoading && } + {error && } + + + + + {_("Name")} + {_("Type")} + + + + {availableFiles.map((fileSource) => ( + + + + + + {fileSource.file_type && ( + + {fileSource.file_type} + + )} + + + ))} + +
+ + + + + + + +
+ ) +} + +export default AgentFileSourcesTab diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentForm.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentForm.tsx new file mode 100644 index 000000000..dd1817836 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentForm.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react" +import { useFormState, useWatch } from "react-hook-form" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@components/ui/tabs" +import { BotIcon, CodeIcon, CpuIcon, FileTextIcon, FolderIcon, SparklesIcon, SquareFunctionIcon } from "lucide-react" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import InstructionField from "../ai/InstructionField" +import AgentGeneralTab from "./AgentGeneralTab" +import AgentAITab from "./AgentAITab" +import AgentFunctionsTab from "./AgentFunctionsTab" +import AgentDocumentProcessorsTab from "./AgentDocumentProcessorsTab" +import AgentFileSourcesTab from "./AgentFileSourcesTab" +import AgentApiDocsTab from "./AgentApiDocsTab" +import _ from "@lib/translate" + +/** Raven Bot form: General tab always; AI tabs gate on is_ai_bot; API Docs only in edit mode. */ +const AgentForm = ({ isEdit }: { isEdit?: boolean }) => { + const isAiBot = useWatch({ name: "is_ai_bot" }) + const [tab, setTab] = useState("general") + const { errors, submitCount } = useFormState({ name: ["bot_name", "model_provider", "model", "reasoning_effort", "instruction"] }) + + // Hidden tabs stay mounted (forceMount) so their fields validate; jump to the first one with an error. + useEffect(() => { + if (!submitCount) return + if (errors.bot_name) setTab("general") + else if (errors.model_provider || errors.model || errors.reasoning_effort) setTab("ai") + else if (errors.instruction) setTab("instructions") + }, [submitCount]) // eslint-disable-line react-hooks/exhaustive-deps + + return ( + + + + {_("General")} + + {!!isAiBot && ( + <> + + {_("AI")} + + + {_("Instructions")} + + + {_("Functions")} + + + {_("Document Processors")} + + + {_("Files")} + + + )} + {isEdit && ( + + {_("API Docs")} + + )} + + + + + + + + + + + + + + + + + + + + + + + + ) +} + +export default AgentForm diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentFunctionsTab.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentFunctionsTab.tsx new file mode 100644 index 000000000..26ddab8e6 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentFunctionsTab.tsx @@ -0,0 +1,124 @@ +import { useContext, useState } from "react" +import { useFieldArray, useFormContext } from "react-hook-form" +import { useSetAtom } from "jotai" +import { FrappeContext, type FrappeConfig } from "frappe-react-sdk" +import { Trash2Icon } from "lucide-react" +import { Badge } from "@components/ui/badge" +import { Button } from "@components/ui/button" +import { Popover, PopoverContent, PopoverTitle, PopoverTrigger } from "@components/ui/popover" +import LinkFieldCombobox from "@components/common/LinkFieldComboBox/LinkFieldCombobox" +import { settingsDialogOpenTab } from "@components/features/settings/settingsDialogAtom" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import _ from "@lib/translate" + +interface FunctionFields { + message: { type?: string; description?: string } +} + +/** Functions tab of the Raven Bot editor. */ +const AgentFunctionsTab = () => { + const { control } = useFormContext() + const { fields, append, remove } = useFieldArray({ control, name: "bot_functions" }) + const { call } = useContext(FrappeContext) as FrappeConfig + const setOpenTab = useSetAtom(settingsDialogOpenTab) + + const [selectedFunction, setSelectedFunction] = useState("") + const [popoverOpen, setPopoverOpen] = useState(false) + + const onAdd = () => { + if (!selectedFunction) return + call.get("frappe.client.get_value", { + doctype: "Raven AI Function", + filters: { name: selectedFunction }, + fieldname: ["type", "description"], + }).then((res) => { + const { message } = res as FunctionFields + //@ts-expect-error - append accepts a partial row; the rest are server-set defaults + append({ function: selectedFunction, type: message?.type, description: message?.description }) + setSelectedFunction("") + setPopoverOpen(false) + }) + } + + return ( +
+
+

+ {_("Add functions that the bot can use to create or update documents in the system.")} +
+ {_("Create functions in the")}{" "} + {" "} + {_("and then add them here.")} +

+ + + + + +
+ {_("Function")} + field.function)]]} + /> +
+ +
+
+
+
+
+ +
+ {fields.map((field, index) => ( +
+
+
+ + + {field.type} + +
+

{field.description}

+
+ +
+ ))} +
+
+ ) +} + +export default AgentFunctionsTab diff --git a/apps/web/src/components/features/settings/panels/Agents/AgentGeneralTab.tsx b/apps/web/src/components/features/settings/panels/Agents/AgentGeneralTab.tsx new file mode 100644 index 000000000..0f712b7f8 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentGeneralTab.tsx @@ -0,0 +1,46 @@ +import { useFormContext } from "react-hook-form" +import { DataField, SwitchFormField } from "@components/ui/form-elements" +import { Label } from "@components/ui/label" +import { Textarea } from "@components/ui/textarea" +import { useIsMobile } from "@hooks/use-mobile" +import { useRavenSettings } from "@hooks/fetchers/useRavenSettings" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import AINotEnabledCallout from "../ai/AINotEnabledCallout" +import _ from "@lib/translate" + +/** General fields for a Raven Bot — name, description, and the AI agent toggle. */ +const AgentGeneralTab = () => { + const { register } = useFormContext() + const { ravenSettings } = useRavenSettings() + const isMobile = useIsMobile() + + return ( +
+ +
+ +