diff --git a/apps/web/src/components/common/ClearFieldButton.tsx b/apps/web/src/components/common/ClearFieldButton.tsx new file mode 100644 index 000000000..af1e0377b --- /dev/null +++ b/apps/web/src/components/common/ClearFieldButton.tsx @@ -0,0 +1,22 @@ +import { XIcon } from "lucide-react" +import { cn } from "@lib/utils" +import _ from "@lib/translate" + +/** Clear (×) for pickers — a SIBLING of the trigger (button-in-button is invalid markup), + absolutely positioned where the chevron sits; pair with pr-7 on the trigger. */ +const ClearFieldButton = ({ onClick, ariaLabel, className }: { + onClick: () => void + ariaLabel?: string + className?: string +}) => ( + +) + +export default ClearFieldButton diff --git a/apps/web/src/components/common/LinkFieldComboBox/LinkFieldCombobox.tsx b/apps/web/src/components/common/LinkFieldComboBox/LinkFieldCombobox.tsx index 53cee2bee..62600c477 100644 --- a/apps/web/src/components/common/LinkFieldComboBox/LinkFieldCombobox.tsx +++ b/apps/web/src/components/common/LinkFieldComboBox/LinkFieldCombobox.tsx @@ -8,6 +8,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover" import { DrawerClose, DrawerContent, DrawerDescription, DrawerNested, DrawerTitle, DrawerTrigger } from "@components/ui/drawer"; import { FormControl } from "@components/ui/form" import { ChevronDownIcon, ExternalLink } from "lucide-react"; +import ClearFieldButton from "@components/common/ClearFieldButton"; import { Button } from "@components/ui/button"; import { cn } from "@lib/utils"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@components/ui/command"; @@ -140,6 +141,10 @@ export interface LinkFieldComboboxProps { useInForm?: boolean, /** Button Class name */ buttonClassName?: string + /** Extra classes for the dropdown popover (e.g. a max-w cap). */ + dropdownClassName?: string; + /** Show a clear (×) button in place of the chevron while a value is picked. */ + clearable?: boolean; } const LinkFieldCombobox = ({ doctype, @@ -151,13 +156,15 @@ const LinkFieldCombobox = ({ disabled, filterFn, suggestedItems, - placeholder = `Select ${doctype}`, + placeholder = _("Select {0}", [doctype]), customQuery, searchfield, searchAPIPath = "frappe.desk.search.search_link", limit, useInForm, - buttonClassName + buttonClassName, + dropdownClassName, + clearable }: LinkFieldComboboxProps) => { const pageLimit = useMemo(() => limit || getSystemDefault('link_field_results_limit') || 20, [limit]) @@ -282,6 +289,9 @@ const LinkFieldCombobox = ({ ) + const showClear = Boolean(clearable && value && !disabled && !readOnly) + const clearButton = showClear ? onChange("")} /> : null + // The trigger is identical for both shells (popover on desktop, drawer on // mobile), so it's built once and handed to whichever *Trigger wraps it. const trigger = useInForm ? ( @@ -293,13 +303,10 @@ const LinkFieldCombobox = ({ tabIndex={0} disabled={disabled} aria-expanded={open} - // FILTER_TRIGGER_STYLES sizes the trigger to fit its row (w-fit) — this - // one is a form field instead, so w-full overrides just the width half - // of that pairing. `group` scopes the hover-revealed external link - // below. subtle's own bg is already gray-2, so read-only dims the label - // rather than swapping in a background that would look identical to the - // normal state. - className={cn(FILTER_TRIGGER_STYLES, "group w-full", readOnly && "text-ink-gray-5", buttonClassName)}> + // w-full overrides FILTER_TRIGGER_STYLES' w-fit — this one is a form field. + // read-only dims the label; subtle's bg is already gray-2. + // disabled keeps pointer-events so cursor-not-allowed shows; pr-7 = room for the clear ×. + className={cn(FILTER_TRIGGER_STYLES, "group w-full disabled:pointer-events-auto", showClear && "pr-7", readOnly && "text-ink-gray-5", buttonClassName)}> {linkTitle || placeholder} @@ -313,7 +320,7 @@ const LinkFieldCombobox = ({ )} - + {!showClear && } @@ -324,12 +331,12 @@ const LinkFieldCombobox = ({ role="combobox" disabled={disabled} aria-expanded={open} - className={cn(FILTER_TRIGGER_STYLES, "w-full", readOnly && "text-ink-gray-5", buttonClassName)}> + className={cn(FILTER_TRIGGER_STYLES, "w-full disabled:pointer-events-auto", showClear && "pr-7", readOnly && "text-ink-gray-5", buttonClassName)}> {value || placeholder} - + {!showClear && } ) @@ -346,7 +353,7 @@ const LinkFieldCombobox = ({ text-base keeps the taller box on mobile for touch. See FilterCombobox. */} @@ -400,7 +407,10 @@ const LinkFieldCombobox = ({ {/* No disabled/readOnly handling here — the trigger button carries `disabled` itself, and readOnly is enforced in onOpenChange (same as the popover), keeping the read-only look distinct from disabled. */} - {trigger} +
+ {trigger} + {clearButton} +
- - {trigger} - +
+ + {trigger} + + {clearButton} +
{/* max-h-none hands height control to the popover's cap above — cmdk's own diff --git a/apps/web/src/components/common/UploadDocDialog.tsx b/apps/web/src/components/common/UploadDocDialog.tsx new file mode 100644 index 000000000..7a4a26266 --- /dev/null +++ b/apps/web/src/components/common/UploadDocDialog.tsx @@ -0,0 +1,157 @@ +import { useState, type ReactNode } from "react" +import { useFrappeCreateDoc, useFrappeFileUpload } from "frappe-react-sdk" +import { useForm, type DefaultValues, type FieldValues, type UseFormProps, type UseFormReturn } from "react-hook-form" +import { toast } from "sonner" +import { Button } from "@components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@components/ui/dialog" +import ErrorBanner from "@components/ui/error-banner" +import { FileDropzone } from "@components/ui/file-dropzone" +import { Form } from "@components/ui/form" +import { Spinner } from "@components/ui/spinner" +import _ from "@lib/translate" + +type UploadDocDialogProps = { + doctype: string + /** Upload fieldname; the created doc gets the file URL under this key. */ + fileField: string + /** Defaults to private — an upload with no stated intent should not be world-readable. */ + isPrivate?: boolean + accept?: Record + /** Oversized picks are rejected with a toast. */ + maxBytes?: number + title: string + description: string + submitLabel?: string + submitBusyLabel?: string + defaults: DefaultValues + /** react-hook-form validation mode. */ + mode?: UseFormProps["mode"] + /** Form fields, rendered inside the dialog's FormProvider. */ + children: ReactNode + /** Helper text under the dropzone. */ + hint?: ReactNode + /** Seed form fields from the picked file (e.g. a name); cleared fields are the caller's job on `null`. */ + onFilePicked?: (file: File | null, form: UseFormReturn) => void + /** Pre-upload gate (e.g. duplicate check). Return false to abort — set form errors yourself. */ + beforeUpload?: (data: T, form: UseFormReturn) => Promise + /** Docname the file is uploaded against. Defaults to a timestamped placeholder. */ + docname?: (data: T) => string + onCreated: (doc: T & { name: string }) => void | Promise + /** Uncontrolled shell: custom trigger (defaults to a primary button with the title). */ + trigger?: ReactNode + /** Controlled shell: pass both to own the open state; `trigger` is ignored. */ + open?: boolean + onOpenChange?: (open: boolean) => void +} + +/** Dialog that uploads one file and creates a doc pointing at it: dropzone + caller fields + Cancel/submit. */ +const UploadDocDialog = ({ + doctype, fileField, isPrivate = true, accept, maxBytes, title, description, + submitLabel, submitBusyLabel, defaults, mode, children, hint, + onFilePicked, beforeUpload, docname, onCreated, trigger, open, onOpenChange, +}: UploadDocDialogProps) => { + const controlled = open !== undefined + const [internalOpen, setInternalOpen] = useState(false) + const isOpen = controlled ? open : internalOpen + + const form = useForm({ defaultValues: defaults, mode }) + const [files, setFiles] = useState([]) + const { upload, loading: uploading, error: uploadError, reset: resetUpload } = useFrappeFileUpload() + const { createDoc, loading: creating, error: createError, reset: resetCreate } = useFrappeCreateDoc() + const busy = uploading || creating + + const resetState = () => { + form.reset() + setFiles([]) + resetUpload() + resetCreate() + } + + const setOpen = (next: boolean) => { + if (!next) resetState() + if (!controlled) setInternalOpen(next) + onOpenChange?.(next) + } + + const handleSetFiles: React.Dispatch> = (action) => { + const next = typeof action === "function" ? action(files) : action + const file = next[0] + if (file && maxBytes && file.size > maxBytes) { + toast.error(_("File size should not exceed {0}MB", [String(Math.round(maxBytes / (1024 * 1024)))])) + setFiles([]) + onFilePicked?.(null, form) + return + } + setFiles(next) + onFilePicked?.(file ?? null, form) + } + + const onSubmit = async (data: T) => { + const file = files[0] + if (!file) return + try { + if (beforeUpload && !(await beforeUpload(data, form))) return + const res = await upload(file, { + doctype, + docname: docname ? docname(data) : `new-${doctype.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}`, + fieldname: fileField, + isPrivate, + }) + const doc = await createDoc(doctype, { ...data, [fileField]: res.file_url }) + await onCreated(doc) + // Controlled owners close via onCreated; avoid a second onOpenChange(false). + resetState() + if (!controlled) setInternalOpen(false) + } catch { + // Surfaced by the upload/create error banners. + } + } + + return ( + + {!controlled && ( + + {trigger ?? } + + )} + + + {title} + {description} + +
+ + {uploadError && } + {createError && } + + {(hint || accept || maxBytes) ?
+ {hint &&

{hint}

} + {accept &&

{_("Supported formats: {0}", [Object.values(accept).flat().join(", ")])}

} + {maxBytes &&

{_("Maximum file size: {0}MB", [String(Math.round(maxBytes / (1024 * 1024)))])}

} +
: null} + {children} + + + + + + +
+
+ ) +} + +export default UploadDocDialog diff --git a/apps/web/src/components/common/filters/FilterCombobox.tsx b/apps/web/src/components/common/filters/FilterCombobox.tsx index f4025968d..6609f9e1a 100644 --- a/apps/web/src/components/common/filters/FilterCombobox.tsx +++ b/apps/web/src/components/common/filters/FilterCombobox.tsx @@ -2,7 +2,8 @@ import { useState, type ReactNode } from "react" import { Popover, PopoverContent, PopoverTrigger } from "@components/ui/popover" import { Command, CommandEmpty, CommandInput, CommandItem, CommandList } from "@components/ui/command" import { Button } from "@components/ui/button" -import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react" +import { CheckIcon, ChevronDownIcon } from "lucide-react" +import ClearFieldButton from "@components/common/ClearFieldButton" import { cn } from "@lib/utils" import { useIsMobile } from "@hooks/use-mobile" import _ from "@lib/translate" @@ -194,21 +195,7 @@ export function FilterCombobox({ {!onClear && } - {/* A sibling of the trigger rather than a child of it: a button inside a - button is invalid markup, and a span with a click handler would leave - keyboard users no way to clear now that the list has no Clear row. - Absolutely positioned so it takes the chevron's place without the trigger - having to give up any width. */} - {onClear && ( - - )} + {onClear && } 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..0c70457c3 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentAITab.tsx @@ -0,0 +1,228 @@ +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 ( + ( + // overflow-x-clip: the thumb ring overhangs the track at the extremes. +
+
+ + {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..d11153551 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentDocumentProcessorsTab.tsx @@ -0,0 +1,204 @@ +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" +import { isProcessorActive } from "../ai/processorState" + +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..54c7563df --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentEditorView.tsx @@ -0,0 +1,56 @@ +import { AGENTS_LIST_KEY } from "./AgentListView" +import { useContext } from "react" +import { FrappeContext, type FrappeConfig } from "frappe-react-sdk" +import { useNavigate } from "react-router" +import { useSetAtom } from "jotai" +import { toast } from "sonner" +import { ExternalLinkIcon } from "lucide-react" +import { Button } from "@components/ui/button" +import { settingsDialogOpenTab } from "@components/features/settings/settingsDialogAtom" +import type { RavenBot } from "@raven/types/RavenBot/RavenBot" +import SettingsRecordEditor from "../SettingsRecordEditor" +import AgentForm from "./AgentForm" +import _ from "@lib/translate" + +type Props = { id?: string; onBack: () => void; onSaved?: (id: string) => void; onDeleted?: () => void } + +/** AI → Agents editor: create mode when no id, detail/edit mode otherwise. */ +const AgentEditorView = (props: Props) => ( + + {...props} + doctype="Raven Bot" + listKey={AGENTS_LIST_KEY} + createDefaults={{ bot_name: "", description: "", is_ai_bot: 0, enable_file_search: 1, enable_code_interpreter: 1 }} + createTitle={_("Create an Agent")} + backLabel={_("Back to agents")} + deleteDescription={_("This will permanently delete this agent.")} + title={(doc) => {doc.bot_name}} + actions={(doc) => } + form={(isEdit) => } + /> +) + +/** 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..126c967e2 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentFileSourcesTab.tsx @@ -0,0 +1,227 @@ +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} /> + + +
+
+ + {fields.length === 0 ? ( +

+ {_("No files attached yet. Upload a file or select one from your file sources.")} +

+ ) : ( + + + + {_("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"], + limit: 0, // frappe-react-sdk drops a falsy limit → no cap + }, + "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 && } + + {!isLoading && availableFiles.length === 0 ? ( +

+ {_("No more file sources to add.")} +

+ ) : ( + + + + {_("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..8567abfc5 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentForm.tsx @@ -0,0 +1,88 @@ +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 ( + + {/* Scroll on a wrapper — the underline indicator hangs 1px below the list and a scroll container would clip it. */} +
+ + + {_("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..1be0dcd43 --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentFunctionsTab.tsx @@ -0,0 +1,126 @@ +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" +import { errorResponseToast } from "@components/ui/error-banner" + +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) + }).catch((error) => errorResponseToast(_("Could not add function"), error)) + } + + 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)]]} + dropdownClassName="max-w-[268px]" + /> +
+ +
+
+
+
+
+ +
+ {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..39174e8ad --- /dev/null +++ b/apps/web/src/components/features/settings/panels/Agents/AgentGeneralTab.tsx @@ -0,0 +1,48 @@ +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 ( +
+
+ +
+
+ +