From 395442386c1641ec6885e20856258b9ae4262c63 Mon Sep 17 00:00:00 2001 From: Timothy Carambat Date: Wed, 22 Apr 2026 16:41:26 -0600 Subject: [PATCH] Pg 1.12.1 (#5485) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: typo in contribution guidelines, update project metadata and pull_request_temp...md (#5010) Co-authored-by: Timothy Carambat * bump copyright year resolves #5017 * feat: update light mode UI sidebar (#4996) * implement light mode sidebar redesign * Abstract hardcoded hex values into reusable css variables * reorder ternary and apply bold font on hovered workspaces * Remove double icon hack and use a state tracking whether workspace item is being hovered over for fill styles * lint * convert css variables and custom classes to default tailwind classes * remove grab icon filling on hover logic * revert css vars to original values * remove light mode css vars | change bg of sidebar in light mode to right color | make icons correct color in light mode * revert dark mode change --------- Co-authored-by: Timothy Carambat * fix(frontend): fix event listener memory leak in useIsDisabled hook (#5027) fix: optimize event listener management in useIsDisabled hook * feat: dedicated dark theme option with system preference support (#5007) * implement OS level theme switching and dark mode option * simplify * fix logo bug in login | place back useTheme comment --------- Co-authored-by: shatfield4 Co-authored-by: Timothy Carambat * fix cleanup pr workflow * Implement new home page redesign (#4931) * remove legacy home page components, update home page to new layout * update PromptInput component styles to match new designs, make quick action buttons functional * home page chat creates new thread in last used workspace * fix slash commands and agent popup on home page * disable llm workspace selector action in home page * add drag and drop file support to home page * fix behavior of drag and drop on home page * handle pasting attachments in home page * update empty state of workspace chat to use new ui * update empty workspace ui to match home page design, fix flickering loading states * convert quick action buttons to component, add to empty state ws chat * fix hover state light mode in quick actions * add suggested messages subcomponent to empty ws/thread * adjust width, rounded edges of prompt input * only show quick actions for admin/manager role * fix hover states for quick actions and suggested messages component * make upload document quick action trigger parsed document upload * fix mic behavior in homepage, ws chat, ws thread chat * fix margin between prompt input and quick actions * Simplify message presets by removing heading input (#4915) * Remove heading input from message presets, merge legacy headings on edit * filter out empty messages from state after saving * mark form as dirty on input change * styling --------- Co-authored-by: Timothy Carambat * convert SuggestedMessages to component, render SuggestedMessages in home page to target ws * fix broken handleMessageChange reference * add translations for QuickActions * lint * fix home page chat submission broken by PromptInput onChange removal * fix prompt input remount race condition, home page suggested message flicker * remove unused handleSendSuggestedMessage from ChatHistory * add greeting text to main-page translations, remove defaults * fix file deletion in parsed files menu on home page * add virtual thread sidebar state and workspace indicator on home page * show workspace llm selector on home page when workspace exists * show home page for all user roles with rbac quick actions * fix positioning of agent and slash command popups * remove workspace indicator from home page, match empty state spacing * Normalize translations for home page redesign (#4986) * normalize translations * update translations with DMR * accidentally changed es translation * normalize translations for main-page.greeting * update translations with DMR --------- Co-authored-by: Timothy Carambat * update translations * create new workspace in native language Cleanup workspace page from empty state handling * update quick action show logic * fix send button --------- Co-authored-by: Timothy Carambat * fix: GitLab connector infinite loop and rate limit crash for large repos (#5021) * Fix infinite loop and rate limit crashes * simplify logic | add max-retries to fetchNextPage and fetchSingleFileContents --------- Co-authored-by: shatfield4 Co-authored-by: Timothy Carambat * fix: add password character validation to onboarding single-user setup (#5037) * fix single user mode password bug * share const --------- Co-authored-by: Timothy Carambat * Native Tool calling (#5071) * checkpoint * test MCP and flows * add native tool call detection back to LMStudio * add native tool call loops for Ollama * Add ablity detection to DMR (regex parse) * bedrock and generic openai with ENV flag * deepseek native tool calling * localAI native function * groq support * linting, add litellm and OR native tool calling via flag * fix: resolve Gemini agent 400 error on tool call responses (#5054) * add gtc__ prefix to tool call names in Gemini agent message formatting * resolve Gemini agent 400 error on tool call responses * add comments explaining geminis thought signatures --------- Co-authored-by: Timothy Carambat * fix: prevent CMD/CTRL+Arrow scroll from overriding textarea cursor movement (#5053) prevent CMD/CTRL+Arrow scroll from overriding textarea cursor movement Co-authored-by: Timothy Carambat * lint * Normalize scraper runtimeargs for bulk-scraper (#5083) resolves #5078 closes #5079 * resolve Ollama string strict num_ctx resolves #5081 * Lemonade integration (#5077) * lemonade integration * lemonade embedder * log * load model * readme updates * update embedder privacy entry * fix max tool call stack abort flow * v1.11.1 Release tags (#5107) bump tag * 5112 or stream metrics and finish reason (#5117) * update metric tracking for OR + fix finish_reason missing from transitive chunks * linting + comments closes #5113 resolves #5112 * Fix bug where `yarn setup:envs` fails if any .env file already exists. (#5116) Co-authored-by: Timothy Carambat * fix: show actionable error when LMStudio model listing fails or returns empty (#5131) * fix: show actionable error when LMStudio model listing fails or returns empty When the model listing request completes but returns no models (due to connection failure, wrong URL, or server unreachable), the dropdown now shows "No models found — check LMStudio is running and accessible" instead of "--loading available models--", making it possible to distinguish a failed request from one still in progress. Affects both LLM and embedding provider selection components. Closes recurring UX confusion reported in #3519, #1338, #3656. Co-Authored-By: Claude Sonnet 4.6 * UI warning tooltip --------- Co-authored-by: Morgan Giddings Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Timothy Carambat * Add automatic chat mode with native tool calling support (#5140) Introduces a new automatic chat mode (now the default) that automatically invokes tools when the provider supports native tool calling. Conditionally shows/hides the @agent command based on whether native tooling is available. - Add supportsNativeToolCalling() to AI providers (OpenAI, Anthropic, Azure always support; others opt-in via ENV) - Update all locale translations with new mode descriptions - Enhance translator to preserve Trans component tags - Remove deprecated ability tags UI * Revert "Add automatic chat mode with native tool calling support (#5140)" - Need to support documents in agents - Need to support images in agent mode This reverts commit 4c69960dcae605eef418602870b8fb102ed1f1c7. * improve translation script * patch attempt for GH cleanup tag * workflow -wip * fix type * split cleanup * vague GH worker error - try to resolve via repo-name * Test dispatch workflow * Remove test workflow * native tool calling detection for novita * fix sidebar and add translations to sidebar * add translations * Sidebar updates (#5154) * fix sidebar and add translations to sidebar * add translations * Debug cleanup workflow * Debug cleanup workflow * Debug cleanup workflow * Use ALLM_RW_PACKAGES for package cleanup * Remove Google web-search Programmable SERP (#5156) * refactor: refactor agent skills settings page to use i18n translation keys (#5146) * refactor agent skills to read from translation keys instead of hardcoded strings * add missing sql agent description key * Remove fallbacks * adjust translation * swap to factor pattern * normalize translations (#5147) * normalize translations * run translator job * translations --------- Co-authored-by: Timothy Carambat --------- Co-authored-by: Timothy Carambat * chore: add ESLint to `/collector` (#5128) * add eslint config to /collector * prettier formatting * fix unused * fix undefined * disable lines * lockfile --------- Co-authored-by: Timothy Carambat * chore: add ESLint to `/server` (#5126) * add eslint config to server * add break statements to switch case * add support for browser globals and turn off empty catch blocks * disable lines with useless try/catch wrappers * format * fix no-undef errors * disbale lines violating no-unsafe-finally * ignore syncStaticLists.mjs * use proper null check for creatorId instead of unreachable nullish coalescing * remove unneeded typescript eslint comment * make no-unused-private-class-members a warning * disable line for no-empty-objects * add new lint script * fix no-unused-vars violations * make no-unsued-vars an error --------- Co-authored-by: shatfield4 Co-authored-by: Timothy Carambat * Fix: Azure OpenAI model key collision (#5092) * fix: Migrate AzureOpenAI model key from OPEN_MODEL_PREF to prevent the naming collision. No effort necessary from current users. * test: add backwards compat tests for AzureOpenAI model key migration * patch missing env example file * linting --------- Co-authored-by: Timothy Carambat * feat: Add tooltip for paperclip attach button when no files are parsed (#5139) * fix broken tooltip * fix tooltip not showing on homepage * fix tooltip rendering behind input on homepage --------- Co-authored-by: shatfield4 * fix: add missing /wiki to Confluence cloud citation URLs (#5167) fix: add /wiki to Confluence cloud page URLs in citations * Strip thinking from copy message outputs (#5179) * linting & show descriptive error for bad `addtoWorkspace` request body resolves #5172 * Add custom fetch to embedder for Ollama (#5180) Refactor ollama timeout to be shared. Add custom fetch to embedder for ollama as well * chore: add script to detect and prune unused translation keys (#5141) * add script to prune dead translation keys * add support for dynamic translation keys * improve performance of script * fix dynamic t() detection and add keyboard shortcut keys to allowlist * rename scripts * change commands --------- Co-authored-by: shatfield4 Co-authored-by: Timothy Carambat * chore: add ESLint CI workflow (#5160) add lint CI GitHub Action * patch plural keys * add ToS for brevity * Remove `use_mlock` from Ollama to solve `WARN` logs in ollama 0.17 resolves #5182 * Implement v2 chat layout designs (#5074) * New chat history layout with chat bubbles (#4985) * new chat history layout, remove message alignment setting * remove orphaned chat alignment hook and MessageDirection * remove workspace profile picture setting and fetch * clean up unnecessary changes * add light mode colors to chat ui and main page backgrounds * update chat message and action icon colors for light mode * update thinking and agent ui, layout, sizing * update user message uploaded images ui * update thought, agent containers to use new colors * add truncatable content with gradient to user chat messages * fix citations margin * implement new edit message UI with save and submit actions * add translations for TruncatableContent subcomponent * remove unused props * fix text colors for default mode chats, agent, thoughts container * Normalize translations for new chat history layout (#5022) * normalize translations * update translations with DMR * lint * fix mismatched home container colors * fix: add password character validation to onboarding single-user setup (#5037) * fix single user mode password bug * share const --------- Co-authored-by: Timothy Carambat * Native Tool calling (#5071) * checkpoint * test MCP and flows * add native tool call detection back to LMStudio * add native tool call loops for Ollama * Add ablity detection to DMR (regex parse) * bedrock and generic openai with ENV flag * deepseek native tool calling * localAI native function * groq support * linting, add litellm and OR native tool calling via flag * fix: resolve Gemini agent 400 error on tool call responses (#5054) * add gtc__ prefix to tool call names in Gemini agent message formatting * resolve Gemini agent 400 error on tool call responses * add comments explaining geminis thought signatures --------- Co-authored-by: Timothy Carambat * fix: prevent CMD/CTRL+Arrow scroll from overriding textarea cursor movement (#5053) prevent CMD/CTRL+Arrow scroll from overriding textarea cursor movement Co-authored-by: Timothy Carambat * linting, assistant speaker spacing and order, copy/edit order --------- Co-authored-by: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Co-authored-by: Timothy Carambat * Implement new citations UI (#5038) * new chat history layout, remove message alignment setting * remove orphaned chat alignment hook and MessageDirection * remove workspace profile picture setting and fetch * clean up unnecessary changes * add light mode colors to chat ui and main page backgrounds * update chat message and action icon colors for light mode * update thinking and agent ui, layout, sizing * update user message uploaded images ui * update thought, agent containers to use new colors * add truncatable content with gradient to user chat messages * fix citations margin * implement new edit message UI with save and submit actions * add translations for TruncatableContent subcomponent * remove unused props * fix text colors for default mode chats, agent, thoughts container * Normalize translations for new chat history layout (#5022) * normalize translations * update translations with DMR * lint * fix mismatched home container colors * implement new citations ui with sources sidebar * bottom sheet for mobile citations * convert mobile citations bottom sheet to new modal design * add score, border separators for mobile citations modal * push down sources sidebar in password/multiuser mode * fix animation gap, simplify sources sidebar by splitting state to persist data on animation * add english translations * fix spacing from citations sidebar when user has auth * Normalize translations for new citation UI (#5087) * normalize translations * update translations using DMR * fix pluralize to use i18n native solution change reset to immediate clear fix spacing for TTS when showing or not to not have space * proper pluralize * hide metrics on mobile, fix last message padding on mobile --------- Co-authored-by: Timothy Carambat * New prompt input ui/tools menu (#5070) * wip new prompt input ui/tools menu * fix colors for prompt input * redesign workspace llm selector, extract text size + model picker to components * refactor ToolsMenu component * fix colors/refactor WorkspaceModelPicker * fix spacing in ws model picker, change order of tools menu tabs * fix slash commands showing /reset instead of /exit during active agent session * refactor ToolsMenu to be much simpler * cleanup, fix behavior of setupup provider in WorkspaceModelPicker * simplify AgentSkillsTab toggle logic * add english translations for new components * remove legacy slash command/agent popups, add ToolsMenu keyboard nav * fix spacing of workspace model picker text * fix SourcesSidebar and TextSizeMenu positioning after merge * fix keyboard nav in ToolsMenu when clicking on tools button to open * typo * only auto pop up tools menu when prompt input is empty with / * fix z index for tools menu on citation * fix behavior of / in prompt input * move global window agent session state to module level variable * fix prompt input not clearing on /reset * missing translations * revert translating slash command * fix STT auto-submit not working on home page * Normalize translations for new prompt input/tools menu UI (#5130) * normalize translations * update translations using DMR script * normalize translations * update translations using DMR script * remove slash_exit * fix skills.js import after merge * fix tooltip z-index rendering behind citations * patch translation prune script to not remove special cases * updates to tools input * factory translations * use safeJsonParse in clearPromptInputDraft * normalize translations * disable agent skill toggles during active agent sessions + show tooltip on disabled * normalize translations * handle enter key behavior when tools menu is open * fix unfocusable modal for slash command edit/new * fix sending prompt when editing/creating slash commands * hide/show agent skills in tools menu based on role * container borders for dark/light mode compliance to designs --------- Co-authored-by: Timothy Carambat * update how tooltip works for agent menu * update prompt input to show agent button with CTA in agent panel for user clarify update agent session start prompt button in input * translations * translations + move regex for slash commands to constants * fix open sidebar ux * fix tools menu to always open to slash commands, dismiss auto pop up * fix sidebar open/close button overlapping with ws model picker --------- Co-authored-by: Sean Hatfield Co-authored-by: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> * patch slashcommand popup to be usePortal * Improve zh_TW Traditional Chinese locale * Improve zh_TW Traditional Chinese locale (#5187) * lint * fix schema not persisting in DB connector * Improve build times for tests and lint (#5193) * test build skip * reset file * Support Agent stream metric reporting (#5197) * Report citations for Agent call stacks (#5199) * sanitize promptReply Output * Add FileRow Indentation on Documents Picker (#5201) * Fix SQL injection in SQL Agent plugin via parameterized queries Replace string concatenation with parameterized queries in all database connectors to prevent SQL injection through LLM-generated table names. Changes: - PostgreSQL: Use $1, $2 placeholders with pg client parameterization - MySQL: Use ? placeholders with mysql2 execute() prepared statements - MSSQL: Use @p0 placeholders with request.input() parameterization - Update handlers to support parameterized query objects - Add formatQueryForDisplay() for logging parameterized queries Security: Mitigates potential SQL injection when LLM passes unsanitized user input as table_name parameter to getTableSchemaSql/getTablesSql. GHSA-jwjx-mw2p-5wc7 * Align Manager API access with frontend access GHSA-wfq3-65gm-3g2p * Enforce user suspension check on browser extension API key path Previously, suspended users could continue using browser extension endpoints if they had created an API key before suspension. The normal JWT session path blocked suspended users, but the browser extension middleware did not. Changes: - Add suspension and user existence checks to validBrowserExtensionApiKey - Delete browser extension API keys when a user is deleted - Add deleteAllForUser method to BrowserExtensionApiKey model GHSA-7754-8jcc-2rg3 * Fix potential Zip Slip path traversal in community plugin import Validate all ZIP entries before extraction in importCommunityItemFromUrl() to prevent path traversal attacks (CWE-22). Malicious ZIP entries with paths like "../../" could write files outside the intended plugin folder. Requires admin privileges and explicit opt-in to unverified hub downloads. GHSA-rh66-4w74-cf4m * Remove `WelcomeMessages` from app - no longer used (#5206) * remove `WelcomeMessages` from app - no longer user * update erronous alert message * fix job collision ref * fix jobs - remove dev job * Fix potential IDOR vulnerability in workspace parsed files endpoints Add ownership validation to prevent users from deleting or embedding parsed files that don't belong to them. Previously, the delete and embed endpoints only validated authentication but not resource ownership, allowing users to delete attached files for users within workspaces they are also a member of. Changes: - Delete endpoint now filters by userId and workspaceId - Embed endpoint validates file belongs to user and workspace (redundant) - delete() returns false when no matching records found (returns 403) - Added JSDoc comments for clarity GHSA-p5rf-8p88-979c * add user id to chat feedback update JSDOC on middleware for typedef GHSA-2qmm-82f7-8qj5 * feat: Add document count indicators to workspace document management modal (#5207) * add document counts to non-embedded and embedded documents * Update logic to not count search filtered documents * refactor how count is done and rendered * translations --------- Co-authored-by: Timothy Carambat * feat(agents): Add Perplexity Search API as web search provider (#5210) * feat(agents): Add Perplexity Search API as web search provider Adds Perplexity as a search provider for the agent web-browsing plugin, using the Perplexity Search API (POST /search) which returns raw ranked web results — distinct from the existing Perplexity LLM integration. Co-Authored-By: Claude Opus 4.6 * chore: replace docs.perplexity.ai with console.perplexity.ai * chore: replace docs.perplexity.ai with console.perplexity.ai --------- Co-authored-by: kesku Co-authored-by: Claude Opus 4.6 Co-authored-by: Timothy Carambat * bump version tag 1.11.2 * update exa search provider description (#5225) * update exa search provider description Co-Authored-By: ishan * update exa search provider description Co-Authored-By: ishan --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ishan Co-authored-by: Timothy Carambat * Automatic mode for workspace (Agent mode default) (#5143) * Add automatic chat mode with native tool calling support Introduces a new automatic chat mode (now the default) that automatically invokes tools when the provider supports native tool calling. Conditionally shows/hides the @agent command based on whether native tooling is available. - Add supportsNativeToolCalling() to AI providers (OpenAI, Anthropic, Azure always support; others opt-in via ENV) - Update all locale translations with new mode descriptions - Enhance translator to preserve Trans component tags - Remove deprecated ability tags UI * rebase translations * WIP on image attachments. Supports initial image attachment + subsequent attachments * persist images * Image attachments and updates for providers * desktop pre-change * always show command on failure * add back gemini streaming detection * move provider native tooling flag to Provider func * whoops - forgot to delete * strip "@agent" from prompts to prevent weird replies * translations for automatic-mode (#5145) * translations for automatic-mode * rebase * translations * lint * fix dead translations * change default for now to chat mode just for rollout * remove pfp for workspace * passthrough workspace for showAgentCommand detection and rendering * Agent API automatic mode support * ephemeral attachments passthrough * support reading of pinned documents in agent context * MCP tool manager (#5230) * MCP tool manager * Mcp tool manager i18 (#5231) i18n translations for MCP manager changes connect #5230 * fix bad i18n key * Intelligent Skill Selection (#5236) * Beta Intelligent Tooling todo: Agent Skill banner warning when tool # is high or % of content window? * forgot files * add UI controls and maxToolCallStack setting * update docs link * ISS i18n (#5237) i18n * README updates (#5238) * README updates * Update README.md * Update README.md * remove unused images * updates * copy updates * fix(collector): infer file extension from Content-Type for URLs without explicit extensions (#5252) * fix(collector): infer file extension from Content-Type for URLs without explicit extensions When downloading files from URLs like https://arxiv.org/pdf/2307.10265, the path has no recognizable file extension. The downloaded file gets saved without an extension (or with a nonsensical one like .10265), causing processSingleFile to reject it with 'File extension .10265 not supported for parsing'. Fix: after downloading, check if the filename has a supported file extension. If not, inspect the response Content-Type header and map it to the correct extension using the existing ACCEPTED_MIMES table. For example, a response with Content-Type: application/pdf will cause the file to be saved with a .pdf extension, allowing it to be processed correctly. Fixes #4513 * small refactor --------- Co-authored-by: Timothy Carambat * feat: add Lithuanian locale and register in resources (#5243) * feat: add Lithuanian locale and register in resources * sync --------- Co-authored-by: Timothy Carambat * Telegram bot connector (#5190) * wip telegram bot connector * encrypt bot token, reorg telegram bot modules, secure pairing codes * offload telegram chat to background worker, add @agent support with chart png rendering, reconnect ui * refactor telegram bot settings page into subcomponents * response.locals for mum, telemetry for connecting to telegram * simplify telegram command registration * improve telegram bot ux: rework switch/history/resume commands * add voice, photo, and TTS support to telegram bot with long message handling * lint * rename external_connectors to external_communication_connectors, add voice response mode, persist chat workspace/thread selection * lint * fix telegram bot connect/disconnect bugs, kill telegram bot on multiuser mode enable * add english translations * fix qr code in light mode * repatch migration * WIP checkpoint * pipeline overhaul for using response obj * format functions * fix comment block * remove conditional dumpENV + lint * remove .end() from sendStatus calls * patch broken streaming where streaming only first chunk * refactor * use Ephemeral handler now * show metrics and citations in real GUI * bugfixes * prevent MuM persistence, UI cleanup, styling for status * add new workspace flow in UI Add thread chat count fix 69 byte payload callback limit bug * handle pagination for workspaces, threads, and models * modularize commands and navigation * add /proof support for citation recall * handle backlog message spam * support abort of response streams * code cleanup * spam prevention * fix translations, update voice typing indicator, fix token bug * frontend refactor, update tips on /status and voice response improvements * collapse agent though blocks * support images * Fix mime issues with audio from other devices * fix config issue post server stop * persist image on agentic chats * 5189 i18n (#5245) * i18n translations connect #5189 * prune translations * fix errors * fix translation gaps --------- Co-authored-by: Timothy Carambat * Add User-Agent header for Anthropic API calls (#5174) * Add User-Agent header for Anthropic API calls Passes User-Agent: AnythingLLM/{version} to the Anthropic SDK so Anthropic can identify traffic from AnythingLLM. Co-Authored-By: Claude Opus 4.6 * remove test, simplify header default * unset change to spread --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Timothy Carambat * add Dynamic `max_tokens` retreival for Anthropic models (#5255) * fix Firefox LaTeX rendering (#5258) * fix pruned translations * whitelist valid dynamic translation * add ask to run prompt for tool calls (demo) (#5261) * add ask to run prompt for tools * border-none on buttons * translations * linting * i18n (#5263) * extend approve/deny requests to telegram * break up handler * Refactor onboarding welcome screen to v2 design (#5262) * refactor onboarding home page to v2 design * fixc typography and buttons * refactor useTheme to return isLight variable | call useTheme from inside SVG component | apply light mode background gradient | polish styles to match designs * add welcome i18n * simplify isLight variable * add new welcome translation key to locales * delete unused images * move OnboardingLogoSVG into module | compute isLight directly in component * add type button | add border-none | add hover state * update hook with doc --------- Co-authored-by: Timothy Carambat * Filesystem Agent Skill overhaul (#5260) * wip * collector parse fixes * refactor for class and also operation for reading * add skill management panel * management panel + lint * management panel + lint * Hide skill in non-docker context * add ask-prompt for edit tool calls * fix dep * fix execa pkg (unused in codebase) * simplify search with ripgrep only and build deps * Fs skill i18n (#5264) i18n * add copy file support * fix translations * fix es translation entry * feat : auto-select newly uploaded docs/URLs in my documents list (#5222) * auto-select newly uploaded docs/URLs in My Documents list * fix: improve auto-select reliability and fix debounce/selection bugs - Add missing `await` on fetchKeys in handleSendLink so loading state and auto-select timing work correctly - Use functional update for setSelectedItems to merge with existing selections instead of replacing them - Stabilize debounced fetchKeys with useRef so rapid uploads actually debounce instead of creating independent timers per render - Rename shadowed local variables (availableDocs -> filteredAvailableDocs) for clarity --------- Co-authored-by: Timothy Carambat * remove legacy cost estimate for embedding * feat: add missing Lemonade LLM provider env vars to .env.example (#5275) add llm provider lemonade env vars to .env.example * fix openapi spec * feat: add optional API key support for Lemonade provider (#5281) * add API key param to Lemonade LLM Provider and Embedding Provider * add LEMONADE_LLM_API_KEY to .env.example * add api key to aibitat provider * fix api key from being sent to frontend * fix tooltip id * add null fallback for `apiKey` * remove console log * add missing api keys --------- Co-authored-by: Timothy Carambat * File creation agent skills (#5280) * Powerpoint File Creation (#5278) * wip * download card * UI for downloading * move to fs system with endpoint to pull files * refactor UI * final-pass * remove save-file-browser skill and refactor * remove fileDownload event * reset * reset file * reset timeout * persist toggle * Txt creation (#5279) * wip * download card * UI for downloading * move to fs system with endpoint to pull files * refactor UI * final-pass * remove save-file-browser skill and refactor * remove fileDownload event * reset * reset file * reset timeout * wip * persist toggle * add arbitrary text creation file * Add PDF document generation with markdown formatting (#5283) add support for branding in bottom right corner refactor core utils and frontend rendering * Xlsx document creation (#5284) add Excel doc & sheet creation * Basic docx creation (#5285) * Basic docx creation * add test theme support + styling and title pages * simplify skill selection * handle TG attachments * send documents over tg * lazy import * pin deps * fix lock * i18n for file creation (#5286) i18n for file-creation connect #5280 * theme overhaul * Add PPTX subagent for better results * forgot files * Add PPTX subagent for better results (#5287) * Add PPTX subagent for better results * forgot files * make sub-agent use proper tool calling if it can and better UI hints * add batching Intelligent Tool Selector for performance and scoring * Automatic mode is now default * show links in /proof on TG * Redesign Telegram bot settings UI (#5306) * redesign telegram bot settings ui/refactor ui components * fix positioning of user row * move ConnectedBotCard to subcomponent * fix redirect * remove redundant guard --------- Co-authored-by: Timothy Carambat * remove log * Fix chat UI event listener bloat (#5323) * 1.12.0 release (#5331) * German translation fixes (#5319) * Fix German login welcome message * More German translation fixes --------- Co-authored-by: Timothy Carambat * fix(lemonade): throw on embedding failures instead of returning empty (#5325) * fix(lemonade): throw on embedding failures instead of returning empty vectors * use class logger --------- Co-authored-by: Timothy Carambat * Fix light mode docgen page (#5347) Fix light mode docgen * fix(agent-flows): keep flow menu visible in narrow windows (#5341) * fix(agent-flows): keep flow menu visible in narrow windows * fix(agent-flows): prevent gear menu text clipping Signed-off-by: suyua9 <1521777066@qq.com> --------- Signed-off-by: suyua9 <1521777066@qq.com> Co-authored-by: Timothy Carambat * Fix Agent Flow toggle state sync (#5348) * hide cluttered menus on small screens resolves #5055 closes #5132 * admin only flow-plugin path validation * Remove illegal chars for Windows on files (#5364) * add provider/embedder to bug report for clarity resolves #5363 * add provider/embedder to bug report for clarity resolves #5363 * Revert "Remove illegal chars for Windows on files (#5364)" This reverts commit 8ed1d35ab3ea271e36efa7b4a42e6792c7ee4108. * Reapply "Remove illegal chars for Windows on files (#5364)" This reverts commit 869be87ef6dcd2076a10c7768b5855213195b86d. * feat: Document Embedding Status Events | Refactor Document Embedding to Job Queue and Forked Process (#5254) * implement native embedder job queue * persist embedding progress across renders * add development worker timeouts * change to static method * native reranker * remove useless return * lint * simplify * make embedding worker timeout value configurable by admin * add event emission for missing data * lint * remove onProgress callback argument * make rerank to rerankDirect * persists progress state across app reloads * remove chunk level progress reporting * remove unuse dvariable * make NATIVE_RERANKING_WORKER_TIMEOUT user configurable * remove dead code * scope embedding progress per-user and clear stale state on SSE reconnect * lint * revert vector databases and embedding engines to call their original methods * simplify rerank * simplify progress fetching by removing updateProgressFromApi * remove duplicate jsdoc * replace sessionStorage persistence with server-side history replay for embedding progress * fix old comment * fix: ignore premature SSE all_complete when embedding hasn't started yet The SSE connection opens before the embedding API call fires, so the server sees no buffered history and immediately sends all_complete. Firefox dispatches this eagerly enough that it closes the EventSource before real progress events arrive, causing the progress UI to clear and fall back to the loading spinner. Chrome's EventSource timing masks the race. Track slugs where startEmbedding was called but no real progress event has arrived yet via awaitingProgressRef. Ignore the first all_complete for those slugs and keep the connection open for the real events. * reduce duplication with progress emissions * remove dead code * refactor: streamline embedding progress handling Removed unnecessary tracking of slugs for premature all_complete events in the EmbeddingProgressProvider. Updated the server-side logic to avoid sending all_complete when no embedding is in progress, allowing the connection to remain open for real events. Adjusted the embedding initiation flow to ensure the server processes the job before the SSE connection opens, improving the reliability of progress updates. * fix stale comment * remove unused function * fix event emissions for document creation failure * refactor: move Reranking Worker Idle Timeout input to LanceDBOptions component Extracted the Reranking Worker Idle Timeout input from GeneralEmbeddingPreference and integrated it into the LanceDBOptions component. This change enhances modularity and maintains a cleaner structure for the settings interface. * lint * remove unused hadHistory vars * refactor workspace directory by hoisting component and converting into functions * moved EmbeddingProgressProvider to wrap Document Manager Modal * refactor embed progress SSE connection to use fetchEventSource instead of native EventSource API. * refactor message handlng into a function and reduce duplication * refactor: utilize writeResponseChunk for event emissions in document embedding progress SSE * refactor: explicit in-proc embedding and rerank methods that are called by workers instead of process.send checks * Abstract EmbeddingProgressBus and Worker Queue into modules * remove error and toast messages on embed process result * use safeJsonParse * add chunk-level progress events with per-document progress bar in UI * remove unused parameter * rename all worker timeout references to use ttl | remove ttl updating from UI * refactor: pass embedding context through job payload instead of global state * lint * add graceful shutdown for workers * apply figma styles * refactor embedding worker to use bree * use existing WorkerQueue class as the management layer for jobs * lint * revert all reranking worker changes back to master state Removes the reranking worker queue, rerankViaWorker/rerankInProcess renames, and NATIVE_RERANKING_WORKER_TTL config so this branch only contains the embedding worker job queue feature. * remove breeManaged flag — WorkerQueue always spawns via Bree * fix prompt embedding bug * have embedTextInput call embedChunksInProcess * add message field to `process.send()` * remove nullish check and error throw * remove bespoke graceful shutdown logix * add spawnWorker method and asbtract redudant flows into helper methods * remove unneeded comment * remove recomputation of TTL value * frontend cleanup and refactor * wip on backend refactor * backend overhaul * small lint * second pass * add logging, update endpoint * simple refactor * add reporting to all embedder providers * fix styles --------- Co-authored-by: Timothy Carambat * Update Lemonade Integration to support v10.1.0 changes (#5378) Update Lemonade Integraion Fix ApiKey nullification check causing hard throw * Enable final tool call in MAX_STACK to run (#5381) * Fix streaming issue for LLM instruction blocks (#5382) * Fix Telegram thread being null, actually wait for disconnect to prevent conflict at runtime * Add retry handling to TG for transient failures (#5391) * Add retry handling to TG for transient failures * add async to promise * Migrate to org-maintained mdpdf for lang support (Hangul, Simplified Chinese, Kanji) (#5392) move to custom mdpdf for lang support (Hangul, Simplified Chinese) * Update TG Transient error code and unclosed tag handler * feat: adds name field to api keys (#5366) * feat: adds name field to api keys * remove extra toasts * prune and norm translations --------- Co-authored-by: Timothy Carambat * Add automatic agent skill aproval via ENV Flag (#5405) * add autoapproval env flag * persist flag * GMail Agent Skill (#5400) * wip * remove label tech * ask to read attachments * update skills * Skill ready and tested * report dynamic citations and generic get mailbox util * norm translations * translations * remove dead code, remove connector in multiUser * simple refactor - dont ask for drafts * refactor filesize helper * norm translations, remove read_messages skill * Helm chart updates (#5410) * move strategy to deployment spec Signed-off-by: Busta Pipes * add optional httproute resource Signed-off-by: Busta Pipes --------- Signed-off-by: Busta Pipes Co-authored-by: Busta Pipes * feat: add Catalan translation (#5411) * Add Catalan translation * lint --------- Co-authored-by: Timothy Carambat * fix: preserve Confluence context paths (#5415) * fix: preserve confluence context paths * lint and minor changes --------- Co-authored-by: Timothy Carambat * Enable chatId reporting during agent sessions (#5407) * 5427 translations (#5429) * Outlook agent via Entra Application * translations * Revert "5427 translations (#5429)" This reverts commit 41727518584491baabf50e10da35f6006be55dfd. * Outlook agent via Entra Application (#5427) * Outlook agent via Entra Application * translations (#5437) * reorder skills for app integrations * Refactor Gmail Agent (#5439) * make DDG default web-search in UI (already is in backend!) * Google calendar skill (#5442) * Google Calendar Agent * forgot files * Translations (#5443) * Image lightbox for chat attachments (#5441) * add image lightbox for chat attachments * wrap lightbox image triggers in button elements * add images to dependency array * add jsdoc to ChatAttachments and remove filter * fix regenerate from system message connect #5407 * dedupe email items based on name * comment on outlook agent * Better citations for gmail, gcal, and outlook * bump TG edit to prevent edit spam for messages since edits count as a send event and too many will result in a 429 resolves #5447 * Merge commit from fork * better special citation styling * Add capability detection and streaming usage for Generic OpenAI provider (#5477) - Add ENV-configurable model capabilities (tools, reasoning, vision, imageGeneration) via PROVIDER_SUPPORTS_* environment variables - Add optional stream usage reporting via GENERIC_OPEN_AI_REPORT_USAGE - Fix streaming tool calls for providers that send null tool_call.id (e.g., mlx-server) by generating fallback UUIDs - Refactor supportsNativeToolCalling() to use centralized capabilities API * fix: omit temperature param for Bedrock Claude Opus 4.7 (#5472) * addconditionally pass temperature based on aws bedrock model id * move to config --------- Co-authored-by: Timothy Carambat * fix: long-prompt bubble flicker & See More collapse on streaming/scroll (#5473) fix ui flickering and truncatable prompt expansion bug Co-authored-by: shatfield4 Co-authored-by: Timothy Carambat * fix: surface readable error messages in web-scraping agent and ai-provider (#5476) * fix: surface readable error messages in web-scraping agent and ai-provider * simplify --------- Co-authored-by: Timothy Carambat * update tool call response to always include convo ID for emails so they are not hallunicated * sync locales * ensure db schema * 1.12.1 release tags (#5483) * bump pg tag --------- Signed-off-by: suyua9 <1521777066@qq.com> Signed-off-by: Busta Pipes Co-authored-by: Dipanshu Rawat Co-authored-by: Marcello Fitton <106866560+angelplusultra@users.noreply.github.com> Co-authored-by: shatfield4 Co-authored-by: Brian Pursley Co-authored-by: Morgan Co-authored-by: Morgan Giddings Co-authored-by: Claude Sonnet 4.6 Co-authored-by: Ryan Co-authored-by: Maxwell Calkin <101308415+MaxwellCalkin@users.noreply.github.com> Co-authored-by: Peter Dave Hello Co-authored-by: Peter Dave Hello <3691490+PeterDaveHello@users.noreply.github.com> Co-authored-by: Kesku <62210496+kesku@users.noreply.github.com> Co-authored-by: kesku Co-authored-by: Ishan Goswami Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ishan Co-authored-by: Yitong Li Co-authored-by: arvydev <55648027+arvydev@users.noreply.github.com> Co-authored-by: Mike Lambert Co-authored-by: Neha Prasad Co-authored-by: S. Neuhaus Co-authored-by: suyua9 <1521777066@qq.com> Co-authored-by: Guilherme Nogueira Co-authored-by: Kurt Co-authored-by: Busta Pipes Co-authored-by: Jordi Mas Co-authored-by: Asish Kumar <87874775+officialasishkumar@users.noreply.github.com> Co-authored-by: Akhil <133588800+Akhil373@users.noreply.github.com> --- .github/ISSUE_TEMPLATE/01_bug.yml | 18 + .../workflows/build-and-push-pg-image.yaml | 2 +- .../helm/charts/anythingllm/README.md | 4 +- .../helm/charts/anythingllm/README.md.gotmpl | 2 +- .../anythingllm/templates/deployment.yaml | 8 +- .../anythingllm/templates/httproute.yaml | 38 + .../helm/charts/anythingllm/values.yaml | 40 +- .../Confluence/ConfluenceLoader.test.js | 125 ++ collector/package.json | 2 +- .../utils/extensions/Confluence/index.js | 32 +- collector/utils/files/index.js | 16 +- docker/.env.example | 4 + docker/Dockerfile | 2 +- frontend/src/App.jsx | 2 + frontend/src/EmbeddingProgressContext.jsx | 240 +++ .../src/components/ImageLightbox/index.jsx | 115 ++ .../LLMSelection/LemonadeOptions/index.jsx | 4 +- .../Documents/WorkspaceDirectory/index.jsx | 187 +- .../ManageWorkspace/Documents/index.jsx | 78 +- .../Modals/ManageWorkspace/index.jsx | 9 +- .../ChatHistory/Chartable/index.jsx | 5 +- .../ChatHistory/Citation/index.jsx | 64 +- .../ChatHistory/HistoricalMessage/index.jsx | 35 +- .../PromptInput/Attachments/index.jsx | 38 +- .../SourcesSidebar/SourceItem/index.jsx | 10 +- .../ChatContainer/TextSizeMenu/index.jsx | 2 + .../WorkspaceModelPicker/index.jsx | 3 +- frontend/src/locales/ar/common.js | 428 ++++- frontend/src/locales/ca/common.js | 1533 +++++++++++++++++ frontend/src/locales/cs/common.js | 434 ++++- frontend/src/locales/da/common.js | 428 ++++- frontend/src/locales/de/common.js | 376 +++- frontend/src/locales/en/common.js | 421 ++++- frontend/src/locales/es/common.js | 453 ++++- frontend/src/locales/et/common.js | 429 ++++- frontend/src/locales/fa/common.js | 425 ++++- frontend/src/locales/fr/common.js | 453 ++++- frontend/src/locales/he/common.js | 412 ++++- frontend/src/locales/it/common.js | 448 ++++- frontend/src/locales/ja/common.js | 421 ++++- frontend/src/locales/ko/common.js | 416 ++++- frontend/src/locales/lt/common.js | 353 +++- frontend/src/locales/lv/common.js | 440 ++++- frontend/src/locales/nl/common.js | 439 ++++- frontend/src/locales/pl/common.js | 448 ++++- frontend/src/locales/pt_BR/common.js | 449 ++++- frontend/src/locales/resources.js | 4 + frontend/src/locales/ro/common.js | 446 ++++- frontend/src/locales/ru/common.js | 445 ++++- frontend/src/locales/tr/common.js | 449 ++++- frontend/src/locales/vn/common.js | 434 ++++- frontend/src/locales/zh/common.js | 400 ++++- frontend/src/locales/zh_TW/common.js | 402 ++++- frontend/src/models/admin.js | 3 +- frontend/src/models/googleAgentSkills.js | 45 + frontend/src/models/outlookAgent.js | 65 + frontend/src/models/system.js | 3 +- frontend/src/models/workspace.js | 9 + .../Admin/Agents/AgentFlows/FlowPanel.jsx | 25 +- .../pages/Admin/Agents/AgentFlows/index.jsx | 3 +- .../Agents/CreateFileSkillPanel/index.jsx | 11 +- .../Admin/Agents/GMailSkillPanel/gmail.png | Bin 0 -> 21933 bytes .../Admin/Agents/GMailSkillPanel/index.jsx | 448 +++++ .../Admin/Agents/GMailSkillPanel/utils.js | 136 ++ .../google-calendar.png | Bin 0 -> 12854 bytes .../Agents/GoogleCalendarSkillPanel/index.jsx | 457 +++++ .../Agents/GoogleCalendarSkillPanel/utils.js | 114 ++ .../Admin/Agents/OutlookSkillPanel/index.jsx | 667 +++++++ .../Agents/OutlookSkillPanel/outlook.png | Bin 0 -> 39393 bytes .../Admin/Agents/OutlookSkillPanel/utils.js | 90 + .../Admin/Agents/WebSearchSelection/index.jsx | 32 +- frontend/src/pages/Admin/Agents/index.jsx | 103 +- frontend/src/pages/Admin/Agents/skills.jsx | 137 ++ frontend/src/pages/Admin/Agents/utils.js | 33 + .../ApiKeys/ApiKeyRow/index.jsx | 61 +- .../ApiKeys/NewApiKeyModal/index.jsx | 45 +- .../pages/GeneralSettings/ApiKeys/index.jsx | 11 +- frontend/src/utils/chat/agent.js | 7 + frontend/src/utils/chat/markdown.js | 4 +- frontend/src/utils/constants.js | 6 + package.json | 2 +- server/.env.example | 4 + server/endpoints/admin.js | 11 +- server/endpoints/system.js | 7 +- server/endpoints/telegram.js | 11 +- .../utils/googleAgentSkillEndpoints.js | 70 + server/endpoints/utils/outlookAgentUtils.js | 190 ++ server/endpoints/workspaces.js | 82 + server/index.js | 8 + server/jobs/embedding-worker.js | 199 +++ server/models/apiKeys.js | 7 +- server/models/documents.js | 61 +- server/models/systemSettings.js | 173 ++ server/models/workspaceChats.js | 39 + server/package.json | 4 +- .../20260406120000_init/migration.sql | 2 + server/prisma/schema.prisma | 1 + server/utils/AiProviders/bedrock/index.js | 28 +- .../utils/AiProviders/genericOpenAi/index.js | 61 + server/utils/AiProviders/lemonade/index.js | 64 +- server/utils/BackgroundWorkers/index.js | 87 +- .../EmbeddingEngines/azureOpenAi/index.js | 7 +- server/utils/EmbeddingEngines/cohere/index.js | 7 +- server/utils/EmbeddingEngines/gemini/index.js | 7 +- .../EmbeddingEngines/genericOpenAi/index.js | 7 +- .../utils/EmbeddingEngines/lemonade/index.js | 42 +- .../utils/EmbeddingEngines/liteLLM/index.js | 11 +- .../utils/EmbeddingEngines/lmstudio/index.js | 11 +- .../utils/EmbeddingEngines/localAi/index.js | 11 +- server/utils/EmbeddingEngines/native/index.js | 8 +- server/utils/EmbeddingEngines/ollama/index.js | 6 +- server/utils/EmbeddingEngines/openAi/index.js | 7 +- .../EmbeddingEngines/openRouter/index.js | 7 +- server/utils/EmbeddingWorkerManager.js | 202 +++ .../agentFlows/executors/llm-instruction.js | 16 +- server/utils/agentFlows/index.js | 13 +- server/utils/agents/aibitat/index.js | 71 +- .../agents/aibitat/plugins/chat-history.js | 56 +- .../create-files/pdf/create-pdf-file.js | 2 +- .../gmail/account/gmail-get-mailbox-stats.js | 75 + .../gmail/drafts/gmail-create-draft-reply.js | 220 +++ .../gmail/drafts/gmail-create-draft.js | 217 +++ .../gmail/drafts/gmail-delete-draft.js | 87 + .../plugins/gmail/drafts/gmail-get-draft.js | 84 + .../plugins/gmail/drafts/gmail-list-drafts.js | 96 ++ .../plugins/gmail/drafts/gmail-send-draft.js | 94 + .../gmail/drafts/gmail-update-draft.js | 217 +++ .../agents/aibitat/plugins/gmail/index.js | 73 + .../utils/agents/aibitat/plugins/gmail/lib.js | 573 ++++++ .../plugins/gmail/search/gmail-get-inbox.js | 104 ++ .../plugins/gmail/search/gmail-read-thread.js | 122 ++ .../plugins/gmail/search/gmail-search.js | 121 ++ .../gmail/send/gmail-reply-to-thread.js | 238 +++ .../plugins/gmail/send/gmail-send-email.js | 242 +++ .../plugins/gmail/threads/gmail-mark-read.js | 87 + .../gmail/threads/gmail-mark-unread.js | 87 + .../gmail/threads/gmail-move-to-archive.js | 89 + .../gmail/threads/gmail-move-to-inbox.js | 89 + .../gmail/threads/gmail-move-to-trash.js | 89 + .../calendars/gcal-get-calendar.js | 79 + .../calendars/gcal-list-calendars.js | 82 + .../events/gcal-create-event.js | 233 +++ .../google-calendar/events/gcal-get-event.js | 116 ++ .../events/gcal-get-events-for-day.js | 117 ++ .../google-calendar/events/gcal-get-events.js | 159 ++ .../events/gcal-get-upcoming-events.js | 249 +++ .../google-calendar/events/gcal-quick-add.js | 114 ++ .../events/gcal-set-my-status.js | 125 ++ .../events/gcal-update-event.js | 173 ++ .../aibitat/plugins/google-calendar/index.js | 40 + .../aibitat/plugins/google-calendar/lib.js | 288 ++++ .../agents/aibitat/plugins/http-socket.js | 9 +- server/utils/agents/aibitat/plugins/index.js | 9 + .../account/outlook-get-mailbox-stats.js | 78 + .../outlook/drafts/outlook-create-draft.js | 232 +++ .../outlook/drafts/outlook-delete-draft.js | 86 + .../outlook/drafts/outlook-list-drafts.js | 80 + .../outlook/drafts/outlook-send-draft.js | 82 + .../outlook/drafts/outlook-update-draft.js | 132 ++ .../agents/aibitat/plugins/outlook/index.js | 45 + .../agents/aibitat/plugins/outlook/lib.js | 1413 +++++++++++++++ .../outlook/search/outlook-get-inbox.js | 75 + .../outlook/search/outlook-read-thread.js | 131 ++ .../plugins/outlook/search/outlook-search.js | 101 ++ .../outlook/send/outlook-send-email.js | 222 +++ .../agents/aibitat/plugins/web-scraping.js | 7 +- .../utils/agents/aibitat/plugins/websocket.js | 8 + .../agents/aibitat/providers/ai-provider.js | 6 +- .../agents/aibitat/providers/genericOpenAi.js | 16 +- .../aibitat/providers/helpers/tooled.js | 12 +- .../agents/aibitat/providers/lemonade.js | 2 +- server/utils/agents/defaults.js | 76 +- server/utils/chats/agents.js | 8 +- server/utils/chats/apiChatHandler.js | 11 +- server/utils/files/index.js | 16 + server/utils/files/multer.js | 14 +- server/utils/helpers/agents.js | 35 + server/utils/helpers/index.js | 36 + server/utils/helpers/updateENV.js | 9 +- server/utils/telegramBot/constants.js | 3 +- server/utils/telegramBot/index.js | 130 +- server/utils/telegramBot/utils/format.js | 47 +- server/yarn.lock | 48 + 183 files changed, 24259 insertions(+), 687 deletions(-) create mode 100644 cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml create mode 100644 collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js create mode 100644 frontend/src/EmbeddingProgressContext.jsx create mode 100644 frontend/src/components/ImageLightbox/index.jsx create mode 100644 frontend/src/locales/ca/common.js create mode 100644 frontend/src/models/googleAgentSkills.js create mode 100644 frontend/src/models/outlookAgent.js create mode 100644 frontend/src/pages/Admin/Agents/GMailSkillPanel/gmail.png create mode 100644 frontend/src/pages/Admin/Agents/GMailSkillPanel/index.jsx create mode 100644 frontend/src/pages/Admin/Agents/GMailSkillPanel/utils.js create mode 100644 frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/google-calendar.png create mode 100644 frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/index.jsx create mode 100644 frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/utils.js create mode 100644 frontend/src/pages/Admin/Agents/OutlookSkillPanel/index.jsx create mode 100644 frontend/src/pages/Admin/Agents/OutlookSkillPanel/outlook.png create mode 100644 frontend/src/pages/Admin/Agents/OutlookSkillPanel/utils.js create mode 100644 frontend/src/pages/Admin/Agents/skills.jsx create mode 100644 frontend/src/pages/Admin/Agents/utils.js create mode 100644 server/endpoints/utils/googleAgentSkillEndpoints.js create mode 100644 server/endpoints/utils/outlookAgentUtils.js create mode 100644 server/jobs/embedding-worker.js create mode 100644 server/prisma/migrations/20260406120000_init/migration.sql create mode 100644 server/utils/EmbeddingWorkerManager.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/index.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/lib.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js create mode 100644 server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/index.js create mode 100644 server/utils/agents/aibitat/plugins/google-calendar/lib.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/index.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/lib.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js create mode 100644 server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js create mode 100644 server/utils/helpers/agents.js diff --git a/.github/ISSUE_TEMPLATE/01_bug.yml b/.github/ISSUE_TEMPLATE/01_bug.yml index 582670a08..9a6c75f3a 100644 --- a/.github/ISSUE_TEMPLATE/01_bug.yml +++ b/.github/ISSUE_TEMPLATE/01_bug.yml @@ -40,3 +40,21 @@ body: quickly. This is not required, but it is helpful. validations: required: false + + - type: textarea + id: llm-provider + attributes: + label: LLM Provider & Model (if applicable) + description: What LLM provider and model are you using? (e.g., OpenAI GPT-4, Anthropic, Ollama, etc.) + placeholder: e.g., Ollama / qwen2.5-coder-32b-instruct + validations: + required: false + + - type: textarea + id: embedder-provider + attributes: + label: Embedder Provider & Model (if applicable) + description: What embedding provider and model are you using? (e.g., OpenAI text-embedding-ada-002, Lemonade, etc.) + placeholder: e.g., Lemonade / nomic-embed-text-v1-GGUF + validations: + required: false diff --git a/.github/workflows/build-and-push-pg-image.yaml b/.github/workflows/build-and-push-pg-image.yaml index 0a0aa24a4..eedef6167 100644 --- a/.github/workflows/build-and-push-pg-image.yaml +++ b/.github/workflows/build-and-push-pg-image.yaml @@ -63,7 +63,7 @@ jobs: ${{ steps.dockerhub.outputs.enabled == 'true' && 'mintplexlabs/anythingllm' || '' }} tags: | type=raw,value=pg - type=raw,value=pg-1.12.0 + type=raw,value=pg-1.12.1 - name: Build and push multi-platform Docker image uses: docker/build-push-action@v6 diff --git a/cloud-deployments/helm/charts/anythingllm/README.md b/cloud-deployments/helm/charts/anythingllm/README.md index 250e3fac7..865af2c23 100644 --- a/cloud-deployments/helm/charts/anythingllm/README.md +++ b/cloud-deployments/helm/charts/anythingllm/README.md @@ -58,7 +58,7 @@ Notes: ```yaml image: repository: mintplexlabs/anythingllm - tag: "1.12.0" + tag: "1.12.1" service: type: ClusterIP @@ -104,7 +104,7 @@ helm install my-anythingllm ./anythingllm -f values-secret.yaml | fullnameOverride | string | `""` | | | image.pullPolicy | string | `"IfNotPresent"` | | | image.repository | string | `"mintplexlabs/anythingllm"` | | -| image.tag | string | `"1.12.0"` | | +| image.tag | string | `"1.12.1"` | | | imagePullSecrets | list | `[]` | | | ingress.annotations | object | `{}` | | | ingress.className | string | `""` | | diff --git a/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl b/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl index ecf5421fa..4a4b044d5 100644 --- a/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl +++ b/cloud-deployments/helm/charts/anythingllm/README.md.gotmpl @@ -69,7 +69,7 @@ Notes: ```yaml image: repository: mintplexlabs/anythingllm - tag: "1.12.0" + tag: "1.12.1" service: type: ClusterIP diff --git a/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml b/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml index 7afddc56d..6a9e0b771 100644 --- a/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml +++ b/cloud-deployments/helm/charts/anythingllm/templates/deployment.yaml @@ -9,6 +9,10 @@ spec: selector: matchLabels: {{- include "anythingllm.selectorLabels" . | nindent 6 }} + {{- with .Values.strategy }} + strategy: + {{- toYaml . | nindent 8 }} + {{- end }} template: metadata: {{- with .Values.podAnnotations }} @@ -32,10 +36,6 @@ spec: initContainers: {{- toYaml . | nindent 8 }} {{- end }} - {{- with .Values.strategy }} - strategy: - {{- toYaml . | nindent 8 }} - {{- end }} containers: - name: {{ .Chart.Name }} securityContext: diff --git a/cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml b/cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml new file mode 100644 index 000000000..ca3b3b2ab --- /dev/null +++ b/cloud-deployments/helm/charts/anythingllm/templates/httproute.yaml @@ -0,0 +1,38 @@ +{{- if .Values.httpRoute.enabled -}} +{{- $fullName := include "anythingllm.fullname" . -}} +{{- $svcPort := .Values.service.port -}} +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: {{ $fullName }} + labels: + {{- include "anythingllm.labels" . | nindent 4 }} + {{- with .Values.httpRoute.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +spec: + parentRefs: + {{- with .Values.httpRoute.parentRefs }} + {{- toYaml . | nindent 4 }} + {{- end }} + {{- with .Values.httpRoute.hostnames }} + hostnames: + {{- toYaml . | nindent 4 }} + {{- end }} + rules: + {{- range .Values.httpRoute.rules }} + {{- with .matches }} + - matches: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .filters }} + filters: + {{- toYaml . | nindent 8 }} + {{- end }} + backendRefs: + - name: {{ $fullName }} + port: {{ $svcPort }} + weight: 1 + {{- end }} +{{- end }} diff --git a/cloud-deployments/helm/charts/anythingllm/values.yaml b/cloud-deployments/helm/charts/anythingllm/values.yaml index a08b6edf9..13568c0cb 100644 --- a/cloud-deployments/helm/charts/anythingllm/values.yaml +++ b/cloud-deployments/helm/charts/anythingllm/values.yaml @@ -8,7 +8,7 @@ initContainers: [] image: repository: mintplexlabs/anythingllm pullPolicy: IfNotPresent - tag: "1.12.0" + tag: "1.12.1" imagePullSecrets: [] nameOverride: "" @@ -142,6 +142,44 @@ service: type: ClusterIP port: 3001 +# -- Expose the service via gateway-api HTTPRoute +# Requires Gateway API resources and suitable controller installed within the cluster +# (see: https://gateway-api.sigs.k8s.io/guides/) +httpRoute: + # HTTPRoute enabled. + enabled: false + # HTTPRoute annotations. + annotations: {} + # Which Gateways this Route is attached to. + parentRefs: + - name: gateway + sectionName: http + # namespace: default + # Hostnames matching HTTP header. + hostnames: + - chart-example.local + # List of rules and filters applied. + rules: + - matches: + - path: + type: PathPrefix + value: /headers + # filters: + # - type: RequestHeaderModifier + # requestHeaderModifier: + # set: + # - name: My-Overwrite-Header + # value: this-is-the-only-value + # remove: + # - User-Agent + # - matches: + # - path: + # type: PathPrefix + # value: /echo + # headers: + # - name: version + # value: v2 + ingress: enabled: false className: "" diff --git a/collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js b/collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js new file mode 100644 index 000000000..6633f0ce6 --- /dev/null +++ b/collector/__tests__/utils/extensions/Confluence/ConfluenceLoader.test.js @@ -0,0 +1,125 @@ +/* eslint-env jest, node */ +process.env.STORAGE_DIR = "test-storage"; + +const { resolveConfluenceBaseUrl } = require("../../../../utils/extensions/Confluence"); +const { + ConfluencePagesLoader, +} = require("../../../../utils/extensions/Confluence/ConfluenceLoader"); + +describe("resolveConfluenceBaseUrl", () => { + test("cloud: strips path and returns origin only", () => { + expect( + resolveConfluenceBaseUrl("https://example.atlassian.net/wiki/spaces/SP", true) + ).toBe("https://example.atlassian.net"); + }); + + test("self-hosted: preserves context path, strips trailing slash", () => { + expect( + resolveConfluenceBaseUrl("https://my.domain.com/confluence/", false) + ).toBe("https://my.domain.com/confluence"); + }); + + test("self-hosted: returns origin when no context path", () => { + expect( + resolveConfluenceBaseUrl("https://my.domain.com/", false) + ).toBe("https://my.domain.com"); + }); +}); + +describe("ConfluencePagesLoader", () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe("cloud mode", () => { + test("API requests include /wiki prefix", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ size: 0, results: [] }), + }); + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://example.atlassian.net/wiki/spaces/SP", true), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: true, + }); + + await loader.fetchAllPagesInSpace(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://example.atlassian.net/wiki/rest/api/content?spaceKey=SP&limit=25&start=0&expand=body.storage,version", + expect.any(Object) + ); + }); + + test("page URLs include /wiki prefix", () => { + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://example.atlassian.net/wiki", true), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: true, + }); + + const document = loader.createDocumentFromPage({ + id: "123", + status: "current", + title: "Cloud page", + type: "page", + body: { storage: { value: "

Hello

" } }, + version: { number: 1, by: { displayName: "User" }, when: "2026-01-01T00:00:00.000Z" }, + }); + + expect(document.metadata.url).toBe( + "https://example.atlassian.net/wiki/spaces/SP/pages/123" + ); + }); + }); + + describe("self-hosted mode", () => { + test("API requests use context path without /wiki", async () => { + const fetchMock = jest.spyOn(global, "fetch").mockResolvedValue({ + ok: true, + json: jest.fn().mockResolvedValue({ size: 0, results: [] }), + }); + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://my.domain.com/confluence/", false), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: false, + }); + + await loader.fetchAllPagesInSpace(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://my.domain.com/confluence/rest/api/content?spaceKey=SP&limit=25&start=0&expand=body.storage,version", + expect.any(Object) + ); + }); + + test("page URLs use context path without /wiki", () => { + const loader = new ConfluencePagesLoader({ + baseUrl: resolveConfluenceBaseUrl("https://my.domain.com/confluence/", false), + spaceKey: "SP", + username: "user", + accessToken: "token", + cloud: false, + }); + + const document = loader.createDocumentFromPage({ + id: "123", + status: "current", + title: "Self-hosted page", + type: "page", + body: { storage: { value: "

Hello

" } }, + version: { number: 1, by: { displayName: "User" }, when: "2026-01-01T00:00:00.000Z" }, + }); + + expect(document.metadata.url).toBe( + "https://my.domain.com/confluence/spaces/SP/pages/123" + ); + }); + }); +}); diff --git a/collector/package.json b/collector/package.json index 4be06c38c..f58250969 100644 --- a/collector/package.json +++ b/collector/package.json @@ -1,6 +1,6 @@ { "name": "anything-llm-document-collector", - "version": "1.11.1", + "version": "1.12.1", "description": "Document collector server endpoints", "main": "index.js", "author": "Timothy Carambat (Mintplex Labs)", diff --git a/collector/utils/extensions/Confluence/index.js b/collector/utils/extensions/Confluence/index.js index 7db84d7c6..b4a0b244a 100644 --- a/collector/utils/extensions/Confluence/index.js +++ b/collector/utils/extensions/Confluence/index.js @@ -46,10 +46,11 @@ async function loadConfluence( }; } - const { origin, hostname } = new URL(baseUrl); - console.log(`-- Working Confluence ${origin} --`); + const normalizedBaseUrl = resolveConfluenceBaseUrl(baseUrl, cloud); + const { hostname } = new URL(normalizedBaseUrl); + console.log(`-- Working Confluence ${normalizedBaseUrl} --`); const loader = new ConfluencePagesLoader({ - baseUrl: origin, // Use the origin to avoid issues with subdomains, ports, protocols, etc. + baseUrl: normalizedBaseUrl, spaceKey, username, accessToken, @@ -98,13 +99,13 @@ async function loadConfluence( id: v4(), url: doc.metadata.url + ".page", title: doc.metadata.title || doc.metadata.source, - docAuthor: origin, + docAuthor: normalizedBaseUrl, description: doc.metadata.title, - docSource: `${origin} Confluence`, + docSource: `${normalizedBaseUrl} Confluence`, chunkSource: generateChunkSource( { doc, - baseUrl: origin, + baseUrl: normalizedBaseUrl, spaceKey, accessToken, username, @@ -182,8 +183,9 @@ async function fetchConfluencePage({ } console.log(`-- Working Confluence Page ${pageUrl} --`); + const normalizedBaseUrl = resolveConfluenceBaseUrl(baseUrl, cloud); const loader = new ConfluencePagesLoader({ - baseUrl, // Should be the origin of the baseUrl + baseUrl: normalizedBaseUrl, spaceKey, username, accessToken, @@ -243,6 +245,21 @@ function validBaseUrl(baseUrl) { } } +/** + * Resolves the Confluence base URL, preserving context paths for self-hosted deployments. + * @param {string} baseUrl + * @param {boolean} cloud + * @returns {string} + */ +function resolveConfluenceBaseUrl(baseUrl, cloud = true) { + const url = new URL(baseUrl); + // Cloud URLs use just the origin; self-hosted may have a context path like /confluence + if (cloud) return url.origin; + + const contextPath = url.pathname.replace(/\/+$/, ""); + return `${url.origin}${contextPath}`; +} + /** * Generate the full chunkSource for a specific Confluence page so that we can resync it later. * This data is encrypted into a single `payload` query param so we can replay credentials later @@ -271,4 +288,5 @@ function generateChunkSource( module.exports = { loadConfluence, fetchConfluencePage, + resolveConfluenceBaseUrl, }; diff --git a/collector/utils/files/index.js b/collector/utils/files/index.js index 64f17ec35..e2ec82a3a 100644 --- a/collector/utils/files/index.js +++ b/collector/utils/files/index.js @@ -132,8 +132,9 @@ function writeToServerDocuments({ if (!fs.existsSync(destination)) fs.mkdirSync(destination, { recursive: true }); + const safeFilename = sanitizeFileName(filename); const destinationFilePath = normalizePath( - path.resolve(destination, filename) + ".json" + path.resolve(destination, safeFilename) + ".json" ); fs.writeFileSync(destinationFilePath, JSON.stringify(data, null, 4), { @@ -210,10 +211,19 @@ function normalizePath(filepath = "") { return result; } +/** + * Strips characters that are illegal in Windows filenames, including Unicode + * quotation marks (U+201C, U+201D, etc.) that can get corrupted into ASCII + * double-quotes during charset conversion in the upload pipeline. + * @param {string} fileName - The filename to sanitize. + * @returns {string} - The sanitized filename. + */ function sanitizeFileName(fileName) { if (!fileName) return fileName; - //eslint-disable-next-line - return fileName.replace(/[<>:"\/\\|?*]/g, ""); + return fileName.replace( + /[<>:"/\\|?*\u201C\u201D\u201E\u201F\u2018\u2019\u201A\u201B]/g, + "" + ); } module.exports = { diff --git a/docker/.env.example b/docker/.env.example index 27aa80c9f..fa6a3a164 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -445,3 +445,7 @@ GID='1000' # many tools/MCP servers enabled. # AGENT_SKILL_RERANKER_ENABLED="true" # AGENT_SKILL_RERANKER_TOP_N=15 # (optional) Number of top tools to keep after reranking (default: 15) + +# (optional) Comma-separated list of skills that are auto-approved. +# This will allow the skill to be invoked without user interaction. +# AGENT_AUTO_APPROVED_SKILLS=create-pdf-file,create-word-file diff --git a/docker/Dockerfile b/docker/Dockerfile index e0ea35b8b..5ec961815 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -171,7 +171,7 @@ COPY --chown=anythingllm:anythingllm --from=frontend-build /app/frontend/dist /a # Setup the environment ENV NODE_ENV=production ENV ANYTHING_LLM_RUNTIME=docker -ENV DEPLOYMENT_VERSION=1.12.0 +ENV DEPLOYMENT_VERSION=1.12.1 # Setup the healthcheck HEALTHCHECK --interval=1m --timeout=10s --start-period=1m \ diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7e7b58a5b..32fc4b4f6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -12,6 +12,7 @@ import { FullScreenLoader } from "./components/Preloader"; import { ThemeProvider } from "./ThemeContext"; import { PWAModeProvider } from "./PWAContext"; import KeyboardShortcutsHelp from "@/components/KeyboardShortcutsHelp"; +import ImageLightbox from "@/components/ImageLightbox"; import { ErrorBoundary } from "react-error-boundary"; import ErrorBoundaryFallback from "./components/ErrorBoundaryFallback"; @@ -33,6 +34,7 @@ export default function App() { + diff --git a/frontend/src/EmbeddingProgressContext.jsx b/frontend/src/EmbeddingProgressContext.jsx new file mode 100644 index 000000000..31ddb1a7e --- /dev/null +++ b/frontend/src/EmbeddingProgressContext.jsx @@ -0,0 +1,240 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, +} from "react"; +import { fetchEventSource } from "@microsoft/fetch-event-source"; +import { API_BASE } from "@/utils/constants"; +import { baseHeaders, safeJsonParse } from "@/utils/request"; +import Workspace from "@/models/workspace"; + +const EmbeddingProgressContext = createContext(); + +export function useEmbeddingProgress() { + return useContext(EmbeddingProgressContext); +} + +/** + * Workspace-specific hook that auto-connects SSE on mount and provides + * a callback when progress is cleared (embedding complete + auto-clear timeout). + * @param {string} slug - Workspace slug + * @param {Object} options + * @param {Function} [options.onProgressCleared] - Called when progress transitions from active to cleared + */ +export function useWorkspaceEmbeddingProgress( + slug, + { onProgressCleared } = {} +) { + const { embeddingProgressMap, startEmbedding, connectSSE, removeQueuedFile } = + useEmbeddingProgress(); + const embeddingProgress = embeddingProgressMap[slug] || null; + + // Store callback in ref to avoid stale closures + const onProgressClearedRef = useRef(onProgressCleared); + useEffect(() => { + onProgressClearedRef.current = onProgressCleared; + }, [onProgressCleared]); + + // Auto-connect SSE on mount to catch any active embedding job + useEffect(() => { + connectSSE(slug); + }, [slug, connectSSE]); + + // Detect when progress is cleared (non-null → null) and invoke callback + const prevProgressRef = useRef(embeddingProgress); + useEffect(() => { + if (prevProgressRef.current && !embeddingProgress) { + onProgressClearedRef.current?.(); + } + prevProgressRef.current = embeddingProgress; + }, [embeddingProgress]); + + const removeQueued = useCallback( + (filename) => removeQueuedFile(slug, filename), + [slug, removeQueuedFile] + ); + + return { embeddingProgress, startEmbedding, removeQueuedFile: removeQueued }; +} + +const CLEANUP_DELAY_MS = 1_500; +export function EmbeddingProgressProvider({ children }) { + const [embeddingProgressMap, setEmbeddingProgressMap] = useState({}); + const abortControllersRef = useRef({}); + const cleanupTimeoutsRef = useRef({}); + + useEffect(() => { + return () => { + Object.values(abortControllersRef.current).forEach((ctrl) => + ctrl?.abort() + ); + }; + }, []); + + const updateFileStatus = useCallback( + (slug, filename, status) => + setEmbeddingProgressMap((prev) => ({ + ...prev, + [slug]: { ...prev[slug], [filename]: status }, + })), + [] + ); + + const handleMessage = useCallback( + (slug, msg, ctrl) => { + const data = safeJsonParse(msg.data); + + switch (data.type) { + case "batch_starting": { + const initial = Object.fromEntries( + (data.filenames || []).map((name) => [name, { status: "pending" }]) + ); + setEmbeddingProgressMap((prev) => ({ + ...prev, + [slug]: { ...initial, ...prev[slug] }, + })); + break; + } + + case "doc_starting": + updateFileStatus(slug, data.filename, { + status: "embedding", + chunksProcessed: 0, + totalChunks: 0, + }); + break; + + case "chunk_progress": + updateFileStatus(slug, data.filename, { + status: "embedding", + chunksProcessed: data.chunksProcessed, + totalChunks: data.totalChunks, + }); + break; + + case "doc_complete": + updateFileStatus(slug, data.filename, { status: "complete" }); + break; + + case "doc_failed": + updateFileStatus(slug, data.filename, { + status: "failed", + error: data.error || "Embedding failed", + }); + break; + + case "file_removed": + setEmbeddingProgressMap((prev) => { + const slugMap = { ...prev[slug] }; + delete slugMap[data.filename]; + if (Object.keys(slugMap).length === 0) { + const { [slug]: _, ...rest } = prev; + return rest; + } + return { ...prev, [slug]: slugMap }; + }); + break; + + case "all_complete": + // If there was an error, mark all pending or embedding files as failed + // because something went wrong and we don't know the status of the files + if (data.error) { + setEmbeddingProgressMap((prev) => { + const slugMap = { ...prev[slug] }; + for (const [filename, info] of Object.entries(slugMap)) { + if (info.status === "pending" || info.status === "embedding") { + slugMap[filename] = { + status: "failed", + error: data.error, + }; + } + } + return { ...prev, [slug]: slugMap }; + }); + } + ctrl.abort(); + delete abortControllersRef.current[slug]; + cleanupTimeoutsRef.current[slug] = setTimeout(() => { + setEmbeddingProgressMap((prev) => { + const { [slug]: _, ...rest } = prev; + return rest; + }); + delete cleanupTimeoutsRef.current[slug]; + }, CLEANUP_DELAY_MS); + break; + } + }, + [updateFileStatus] + ); + + /** + * Open (or reconnect) an SSE connection for a given workspace slug. + * Updates embeddingProgressMap in real time as events arrive. + */ + const connectSSE = useCallback( + (slug) => { + if (abortControllersRef.current[slug]) return; + + const ctrl = new AbortController(); + abortControllersRef.current[slug] = ctrl; + + fetchEventSource(`${API_BASE}/workspace/${slug}/embed-progress`, { + method: "GET", + headers: baseHeaders(), + signal: ctrl.signal, + openWhenHidden: true, + onmessage: (msg) => handleMessage(slug, msg, ctrl), + onclose: () => delete abortControllersRef.current[slug], + onerror: () => { + delete abortControllersRef.current[slug]; + throw new Error("SSE connection error"); + }, + }).catch(() => {}); + }, + [handleMessage] + ); + + const startEmbedding = useCallback( + (slug, filenames) => { + abortControllersRef.current[slug]?.abort(); + delete abortControllersRef.current[slug]; + + if (cleanupTimeoutsRef.current[slug]) { + clearTimeout(cleanupTimeoutsRef.current[slug]); + delete cleanupTimeoutsRef.current[slug]; + } + + const newEntries = Object.fromEntries( + filenames.map((name) => [name, { status: "pending" }]) + ); + setEmbeddingProgressMap((prev) => ({ + ...prev, + [slug]: newEntries, + })); + + connectSSE(slug); + }, + [connectSSE] + ); + + const removeQueuedFile = useCallback(async (slug, filename) => { + const { success } = await Workspace.removeQueuedEmbedding(slug, filename); + return success; + }, []); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/ImageLightbox/index.jsx b/frontend/src/components/ImageLightbox/index.jsx new file mode 100644 index 000000000..bd91cd076 --- /dev/null +++ b/frontend/src/components/ImageLightbox/index.jsx @@ -0,0 +1,115 @@ +import { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { X, CaretLeft, CaretRight } from "@phosphor-icons/react"; + +const OPEN_EVENT = "open-image-lightbox"; + +/** + * Opens the image lightbox from anywhere in the app. + * @param {{contentString: string, name: string}[]} images + * @param {number} initialIndex + */ +export function openImageLightbox(images, initialIndex = 0) { + window.dispatchEvent( + new CustomEvent(OPEN_EVENT, { detail: { images, initialIndex } }) + ); +} + +export default function ImageLightbox() { + const [images, setImages] = useState(null); + const [currentIndex, setCurrentIndex] = useState(0); + + useEffect(() => { + function handleOpen(e) { + setImages(e.detail.images); + setCurrentIndex(e.detail.initialIndex); + } + window.addEventListener(OPEN_EVENT, handleOpen); + return () => window.removeEventListener(OPEN_EVENT, handleOpen); + }, []); + + function close() { + setImages(null); + } + + function handlePrevious() { + setCurrentIndex((prev) => (prev > 0 ? prev - 1 : images.length - 1)); + } + + function handleNext() { + setCurrentIndex((prev) => (prev < images.length - 1 ? prev + 1 : 0)); + } + + useEffect(() => { + if (!images) return; + function handleKeyDown(e) { + if (e.key === "Escape") close(); + else if (e.key === "ArrowLeft") handlePrevious(); + else if (e.key === "ArrowRight") handleNext(); + } + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [images]); + + if (!images || images.length === 0) return null; + const safeIndex = Math.min(currentIndex, images.length - 1); + const currentImage = images[safeIndex]; + if (!currentImage) return null; + + return createPortal( +
+ + + {images.length > 1 && ( + <> + + + + )} + + {currentImage.name e.stopPropagation()} + /> + + {images.length > 1 && ( +
+ {safeIndex + 1} / {images.length} +
+ )} +
, + document.getElementById("root") + ); +} diff --git a/frontend/src/components/LLMSelection/LemonadeOptions/index.jsx b/frontend/src/components/LLMSelection/LemonadeOptions/index.jsx index c61bae1f7..9fc5b850d 100644 --- a/frontend/src/components/LLMSelection/LemonadeOptions/index.jsx +++ b/frontend/src/components/LLMSelection/LemonadeOptions/index.jsx @@ -106,7 +106,7 @@ export default function LemonadeOptions({ settings }) { type="url" name="LemonadeLLMBasePath" className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5" - placeholder="http://localhost:8000" + placeholder="http://localhost:13305" value={cleanBasePath(basePathValue.value)} required={true} autoComplete="off" @@ -150,7 +150,7 @@ export default function LemonadeOptions({ settings }) { type="number" name="LemonadeLLMModelTokenLimit" className="border-none bg-theme-settings-input-bg text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5" - placeholder="4096" + placeholder="8192" min={1} value={maxTokens} onChange={(e) => setMaxTokens(Number(e.target.value))} diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx index d75a65e61..83e7b9114 100644 --- a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx +++ b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx @@ -2,7 +2,15 @@ import PreLoader from "@/components/Preloader"; import WorkspaceFileRow from "./WorkspaceFileRow"; import { memo, useEffect, useState } from "react"; import ModalWrapper from "@/components/ModalWrapper"; -import { Eye, PushPin } from "@phosphor-icons/react"; +import { + Eye, + PushPin, + CheckCircle, + XCircle, + CircleNotch, + Clock, + X, +} from "@phosphor-icons/react"; import { SEEN_DOC_PIN_ALERT, SEEN_WATCH_ALERT } from "@/utils/constants"; import paths from "@/utils/paths"; import { Link } from "react-router-dom"; @@ -10,6 +18,8 @@ import Workspace from "@/models/workspace"; import { Tooltip } from "react-tooltip"; import { safeJsonParse } from "@/utils/request"; import { useTranslation } from "react-i18next"; +import { middleTruncate } from "@/utils/directories"; +import { useEmbeddingProgress } from "@/EmbeddingProgressContext"; function WorkspaceDirectory({ workspace, @@ -25,6 +35,8 @@ function WorkspaceDirectory({ movedItems, }) { const { t } = useTranslation(); + const { embeddingProgressMap, removeQueuedFile } = useEmbeddingProgress(); + const embeddingProgress = embeddingProgressMap[workspace.slug] || null; const [selectedItems, setSelectedItems] = useState({}); const embeddedDocCount = (files?.items ?? []).reduce( (sum, folder) => sum + (folder.items?.length ?? 0), @@ -98,12 +110,6 @@ function WorkspaceDirectory({
-
-
-
-

Name

-
-

@@ -115,6 +121,56 @@ function WorkspaceDirectory({ ); } + if (embeddingProgress) { + return ( +

+
+

+ {workspace.name} +

+
+
+
+
+
+

Name

+
+

+ Status +

+
+
+ {Object.entries(embeddingProgress).map(([filename, fileStatus]) => ( + removeQueuedFile(workspace.slug, filename) + : null + } + /> + ))} +
+
+ {hasChanges && movedItems.length > 0 && ( +
+

+ {movedItems.length} additional file(s) ready to embed +

+ +
+ )} +
+ ); + } + return ( <>
@@ -459,4 +515,121 @@ function WorkspaceDocumentTooltips() { ); } +/** + * @param {string} filename + */ +const getDisplayName = (filename) => { + const base = filename.split("/").pop() || filename; + return base.replace( + /-[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.json$/, + "" + ); +}; + +const STATUS_STYLES = { + pending: { + icon: ( + + ), + textColor: "text-slate-100 light:text-slate-900/70", + label: "Queued", + }, + embedding: { + icon: ( + + ), + textColor: "text-slate-100 light:text-slate-900/70", + label: "Embedding", + }, + complete: { + icon: ( + + ), + textColor: "text-green-400 light:text-green-600", + label: "Complete", + }, + failed: { + icon: ( + + ), + textColor: "text-red-400 light:text-red-600", + label: "Failed", + }, +}; + +function EmbeddingFileRow({ filename, status: fileStatus, onRemove }) { + const { status, chunksProcessed = 0, totalChunks = 0 } = fileStatus; + const displayName = getDisplayName(filename); + const isEmbedding = status === "embedding"; + const pct = + isEmbedding && totalChunks > 0 + ? Math.round((chunksProcessed / totalChunks) * 100) + : 0; + + return ( +
+
+ {STATUS_STYLES[status]?.icon || STATUS_STYLES.pending.icon} +

+ {middleTruncate(displayName, 40)} +

+
+
+ {isEmbedding ? ( +
+
+
+
+

{pct}%

+
+ ) : ( +
+

+ {STATUS_STYLES[status]?.label || "Queued"} +

+ {onRemove && ( + + )} +
+ )} +
+
+ ); +} + export default memo(WorkspaceDirectory); diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/index.jsx index 00394aa27..cedb764e5 100644 --- a/frontend/src/components/Modals/ManageWorkspace/Documents/index.jsx +++ b/frontend/src/components/Modals/ManageWorkspace/Documents/index.jsx @@ -1,10 +1,11 @@ import { ArrowsDownUp } from "@phosphor-icons/react"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Workspace from "../../../../models/workspace"; import System from "../../../../models/system"; import showToast from "../../../../utils/toast"; import Directory from "./Directory"; import WorkspaceDirectory from "./WorkspaceDirectory"; +import { useWorkspaceEmbeddingProgress } from "@/EmbeddingProgressContext"; export default function DocumentSettings({ workspace }) { const [highlightWorkspace, setHighlightWorkspace] = useState(false); @@ -21,6 +22,14 @@ export default function DocumentSettings({ workspace }) { availableDocsRef.current = availableDocs; }, [availableDocs]); + const fetchKeysRef = useRef(null); + const { embeddingProgress, startEmbedding } = useWorkspaceEmbeddingProgress( + workspace.slug, + { + onProgressCleared: () => fetchKeysRef.current?.(true), + } + ); + async function fetchKeys(refetchWorkspace = false, options = {}) { const { autoSelectNew = false } = options; const previousIds = new Set(); @@ -31,7 +40,6 @@ export default function DocumentSettings({ workspace }) { } } } - setLoading(true); const localFiles = await System.localFiles(); const currentWorkspace = refetchWorkspace @@ -99,6 +107,10 @@ export default function DocumentSettings({ workspace }) { setLoading(false); } + useEffect(() => { + fetchKeysRef.current = fetchKeys; + }); + useEffect(() => { fetchKeys(true); }, []); @@ -106,36 +118,35 @@ export default function DocumentSettings({ workspace }) { const updateWorkspace = async (e) => { e.preventDefault(); setLoading(true); - showToast("Updating workspace...", "info", { autoClose: false }); setLoadingMessage("This may take a while for large documents"); - const changesToSend = { - adds: movedItems.map((item) => `${item.folderName}/${item.name}`), - }; + const filenames = movedItems.map( + (item) => `${item.folderName}/${item.name}` + ); + const changesToSend = { adds: filenames }; setSelectedItems({}); setHasChanges(false); setHighlightWorkspace(false); - await Workspace.modifyEmbeddings(workspace.slug, changesToSend) - .then((res) => { - if (!!res.message) { - showToast(`Error: ${res.message}`, "error", { clear: true }); - return; - } - showToast("Workspace updated successfully.", "success", { - clear: true, - }); - }) - .catch((error) => { - showToast(`Workspace update failed: ${error}`, "error", { - clear: true, - }); - }); - setMovedItems([]); - await fetchKeys(true); + // Fire the embed POST first so the server is already processing the job + // by the time the SSE connection opens. This avoids the server sending + // idle (no active job) before embedding has started. + const embedPromise = Workspace.modifyEmbeddings( + workspace.slug, + changesToSend + ); + startEmbedding(workspace.slug, filenames); + + embedPromise.catch((error) => { + showToast(`Workspace update failed: ${error}`, "error", { + clear: true, + }); + }); + setLoading(false); setLoadingMessage(""); + setMovedItems([]); }; const moveSelectedItemsToWorkspace = () => { @@ -191,10 +202,29 @@ export default function DocumentSettings({ workspace }) { setSelectedItems({}); }; + const visibleAvailableDocs = useMemo(() => { + const embeddingFilenames = new Set(Object.keys(embeddingProgress ?? {})); + if (embeddingFilenames.size === 0) return availableDocs; + return { + ...availableDocs, + items: (availableDocs.items ?? []).map((folder) => { + if (folder.items && folder.type === "folder") { + return { + ...folder, + items: folder.items.filter( + (file) => !embeddingFilenames.has(`${folder.name}/${file.name}`) + ), + }; + } + return folder; + }), + }; + }, [availableDocs, embeddingProgress]); + return (
{}; const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => { @@ -37,7 +38,7 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => { if (!workspace) return null; - if (isMobile) { + if (isMobileOnly) { return (
@@ -102,7 +103,9 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => { )} {selectedTab === "documents" ? ( - + + + ) : ( )} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Chartable/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Chartable/index.jsx index aab732c9f..6fad58334 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Chartable/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Chartable/index.jsx @@ -31,6 +31,7 @@ import CustomCell from "./CustomCell.jsx"; import Tooltip from "./CustomTooltip.jsx"; import { safeJsonParse } from "@/utils/request.js"; import renderMarkdown from "@/utils/chat/markdown.js"; +import DOMPurify from "dompurify"; import { memo, useCallback, useState } from "react"; import { saveAs } from "file-saver"; import { useGenerateImage } from "recharts-to-png"; @@ -394,7 +395,7 @@ export function Chartable({ props }) {
@@ -413,7 +414,7 @@ export function Chartable({ props }) {
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx index 2fa65bb9f..964b29719 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/Citation/index.jsx @@ -12,6 +12,9 @@ import { LinkSimple, GitlabLogo, } from "@phosphor-icons/react"; +import GmailLogo from "@/pages/Admin/Agents/GMailSkillPanel/gmail.png"; +import GoogleCalendarLogo from "@/pages/Admin/Agents/GoogleCalendarSkillPanel/google-calendar.png"; +import OutlookLogo from "@/pages/Admin/Agents/OutlookSkillPanel/outlook.png"; import { toPercentString } from "@/utils/numbers"; import { useTranslation } from "react-i18next"; import { useSourcesSidebar } from "../../SourcesSidebar"; @@ -28,18 +31,37 @@ const CIRCLE_ICONS = { paperlessNgx: FileText, }; +const CIRCLE_IMAGES = { + gmailThread: GmailLogo, + gmailAttachment: GmailLogo, + googleCalendar: GoogleCalendarLogo, + outlookThread: OutlookLogo, + outlookAttachment: OutlookLogo, +}; + +/** + * Returns the custom image for a given type, or null if no custom image is available. + * @param {string} type + * @returns {string|null} + */ +export function getCustomImage(type) { + return CIRCLE_IMAGES[type] ?? null; +} + /** * Renders a circle with a source type icon inside, or a favicon if URL is provided. * @param {"file"|"link"|"youtube"|"github"|"gitlab"|"confluence"|"drupalwiki"|"obsidian"|"paperlessNgx"} props.type * @param {number} [props.size] - Circle diameter in px * @param {number} [props.iconSize] - Icon size in px * @param {string} [props.url] - Optional URL to fetch favicon from + * @param {string} [props.customImage] - Optional custom image to display */ export function SourceTypeCircle({ type = "file", size = 22, iconSize = 12, url = null, + customImage = null, }) { const Icon = CIRCLE_ICONS[type] || CIRCLE_ICONS.file; const [imgError, setImgError] = useState(false); @@ -60,7 +82,7 @@ export function SourceTypeCircle({ return (
{faviconUrl && !imgError ? ( @@ -71,6 +93,13 @@ export function SourceTypeCircle({ className="object-cover" onError={() => setImgError(true)} /> + ) : customImage ? ( + {type} ) : ( )} @@ -133,10 +162,11 @@ export default function Citations({ sources = [] }) { > {visibleSources.map((source, idx) => { const info = parseChunkSource(source); + const customImage = CIRCLE_IMAGES[info.icon]; return (
); @@ -262,6 +293,11 @@ const supportedSources = [ "youtube://", "obsidian://", "paperless-ngx://", + "gmail-thread://", + "gmail-attachment://", + "google-calendar://", + "outlook-thread://", + "outlook-attachment://", ]; /** @@ -342,6 +378,30 @@ export function parseChunkSource({ title = "", chunks = [] }) { icon = "paperlessNgx"; break; + case "gmail-thread://": + text = title; + icon = "gmailThread"; + break; + case "gmail-attachment://": + text = title; + icon = "gmailAttachment"; + break; + + case "google-calendar://": + text = title; + icon = "googleCalendar"; + break; + + case "outlook-thread://": + text = title; + icon = "outlookThread"; + break; + + case "outlook-attachment://": + text = title; + icon = "outlookAttachment"; + break; + default: text = url.host + url.pathname; icon = "link"; diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index c6c581753..9254e3387 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -1,4 +1,4 @@ -import React, { memo, useEffect, useRef, useState } from "react"; +import React, { memo, useLayoutEffect, useRef, useState } from "react"; import { Info, Warning } from "@phosphor-icons/react"; import Actions from "./Actions"; import renderMarkdown from "@/utils/chat/markdown"; @@ -19,9 +19,10 @@ import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { chatQueryRefusalResponse } from "@/utils/chat"; import HistoricalOutputs from "./HistoricalOutputs"; +import { openImageLightbox } from "@/components/ImageLightbox"; const HistoricalMessage = ({ - uuid = v4(), + uuid: uuidProp, message, role, workspace, @@ -37,6 +38,10 @@ const HistoricalMessage = ({ metrics = {}, outputs = [], }) => { + // Freeze uuid on first render. User messages arrive without a uuid and this value + // is used as the wrapper div's `key` — a default param fallback would regenerate + // on every render and remount the subtree, wiping TruncatableContent state. + const [uuid] = useState(() => uuidProp ?? v4()); const { t } = useTranslation(); const { isEditing } = useEditMessage({ chatId, role }); const { isDeleted, completeDelete, onEndAnimation } = useWatchDeleteMessage({ @@ -205,17 +210,27 @@ export default memo( } ); +/** + * Currently only renders image attachments as clickable thumbnails that open in the lightbox. + * Other attachment types may be supported here in the future. + */ function ChatAttachments({ attachments = [] }) { if (!attachments.length) return null; return (
- {attachments.map((item) => ( - {`Attachment: ( + ))}
); @@ -227,7 +242,9 @@ function TruncatableContent({ children }) { const [isOverflowing, setIsOverflowing] = useState(false); const { t } = useTranslation(); - useEffect(() => { + // useLayoutEffect (not useEffect) so collapse applies before paint — avoids a + // one-frame flash of uncollapsed content on mount. + useLayoutEffect(() => { if (contentRef.current) { setIsOverflowing(contentRef.current.scrollHeight > 250); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx index 6b1e2d1b3..d4e6f5bad 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/Attachments/index.jsx @@ -11,6 +11,7 @@ import { X, } from "@phosphor-icons/react"; import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper"; +import { openImageLightbox } from "@/components/ImageLightbox"; /** * @param {{attachments: import("../../DnDWrapper").Attachment[]}} @@ -18,10 +19,25 @@ import { REMOVE_ATTACHMENT_EVENT } from "../../DnDWrapper"; */ export default function AttachmentManager({ attachments }) { if (attachments.length === 0) return null; + + function handleImageClick(attachment) { + const imageAttachments = attachments + .filter((a) => a.type === "attachment" && a.contentString) + .map((a) => ({ contentString: a.contentString, name: a.file.name })); + const idx = imageAttachments.findIndex( + (img) => img.name === attachment.file?.name + ); + if (idx !== -1) openImageLightbox(imageAttachments, idx); + } + return (
{attachments.map((attachment) => ( - + handleImageClick(attachment)} + /> ))}
); @@ -30,7 +46,7 @@ export default function AttachmentManager({ attachments }) { /** * @param {{attachment: import("../../DnDWrapper").Attachment}} */ -function AttachmentItem({ attachment }) { +function AttachmentItem({ attachment, onImageClick }) { const { uid, file, status, error, document, type, contentString } = attachment; const { iconBgColor, Icon } = displayFromFile(file); @@ -115,12 +131,18 @@ function AttachmentItem({ attachment }) {
- {`Preview +
); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx index 03fb619c3..eccbcd0cb 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx @@ -1,10 +1,15 @@ -import { parseChunkSource, SourceTypeCircle } from "../../ChatHistory/Citation"; +import { + parseChunkSource, + SourceTypeCircle, + getCustomImage, +} from "../../ChatHistory/Citation"; import { useTranslation } from "react-i18next"; export default function SourceItem({ source, onClick }) { const { t } = useTranslation(); const info = parseChunkSource(source); - const subtitle = info.isUrl ? info.text : t("chat_window.document"); + const customImage = getCustomImage(info?.icon); + const subtitle = info?.isUrl ? info?.text : t("chat_window.document"); return ( {open && ( -
+
)} @@ -71,23 +74,15 @@ function ManageFlowMenu({ flow, onDelete }) { ); } -export default function FlowPanel({ flow, toggleFlow, onDelete }) { - const [isActive, setIsActive] = useState(flow.active); - - useEffect(() => { - setIsActive(flow.active); - }, [flow.uuid, flow.active]); - +export default function FlowPanel({ flow, toggleFlow, enabled, onDelete }) { const handleToggle = async () => { try { const { success, error } = await AgentFlows.toggleFlow( flow.uuid, - !isActive + !enabled ); if (!success) throw new Error(error); - setIsActive(!isActive); toggleFlow(flow.uuid); - showToast("Flow status updated successfully", "success", { clear: true }); } catch (error) { console.error("Failed to toggle flow:", error); showToast("Failed to toggle flow", "error", { clear: true }); @@ -106,7 +101,7 @@ export default function FlowPanel({ flow, toggleFlow, onDelete }) {
- +
diff --git a/frontend/src/pages/Admin/Agents/AgentFlows/index.jsx b/frontend/src/pages/Admin/Agents/AgentFlows/index.jsx index 3bb6ab60b..bac6a90a1 100644 --- a/frontend/src/pages/Admin/Agents/AgentFlows/index.jsx +++ b/frontend/src/pages/Admin/Agents/AgentFlows/index.jsx @@ -5,6 +5,7 @@ export default function AgentFlowsList({ flows = [], selectedFlow, handleClick, + activeFlowIds = [], }) { if (flows.length === 0) { return ( @@ -43,7 +44,7 @@ export default function AgentFlowsList({
{flow.name}
- {flow.active ? "On" : "Off"} + {activeFlowIds.includes(flow.uuid) ? "On" : "Off"}
- +
- + {skill.title} - {skill.description} + + {skill.description} +
diff --git a/frontend/src/pages/Admin/Agents/GMailSkillPanel/gmail.png b/frontend/src/pages/Admin/Agents/GMailSkillPanel/gmail.png new file mode 100644 index 0000000000000000000000000000000000000000..2beed80336cbf8265ed2c729daab704e2fea1167 GIT binary patch literal 21933 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 VEhT{m{HGzQ9`v5phuc=c{{hag;jRDx literal 0 HcmV?d00001 diff --git a/frontend/src/pages/Admin/Agents/GMailSkillPanel/index.jsx b/frontend/src/pages/Admin/Agents/GMailSkillPanel/index.jsx new file mode 100644 index 000000000..ad39d9768 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/GMailSkillPanel/index.jsx @@ -0,0 +1,448 @@ +import React, { + useEffect, + useState, + useRef, + useMemo, + useCallback, +} from "react"; +import Toggle, { SimpleToggleSwitch } from "@/components/lib/Toggle"; +import { Trans, useTranslation } from "react-i18next"; +import debounce from "lodash.debounce"; +import { + MagnifyingGlass, + CircleNotch, + Warning, + CaretDown, + CheckCircle, + Info, +} from "@phosphor-icons/react"; +import GMailIcon from "./gmail.png"; +import Admin from "@/models/admin"; +import System from "@/models/system"; +import GoogleAgentSkills from "@/models/googleAgentSkills"; +import { getGmailSkills, filterSkillCategories } from "./utils"; +import { Tooltip } from "react-tooltip"; +import { Link } from "react-router-dom"; +import paths from "@/utils/paths"; + +export default function GMailSkillPanel({ + title, + skill, + toggleSkill, + enabled = false, + disabled = false, + setHasChanges, + hasChanges = false, +}) { + const { t } = useTranslation(); + const [disabledSkills, setDisabledSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [deploymentId, setDeploymentId] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [isMultiUserMode, setIsMultiUserMode] = useState(false); + const [configDefaultExpanded, setConfigDefaultExpanded] = useState(true); + const prevHasChanges = useRef(hasChanges); + const skillCategories = getGmailSkills(t); + + useEffect(() => { + setLoading(true); + Promise.all([ + Admin.systemPreferencesByFields(["disabled_gmail_skills"]), + System.keys(), + GoogleAgentSkills.gmail.getStatus(), + ]) + .then(([prefsRes, settingsRes, statusRes]) => { + setDisabledSkills(prefsRes?.settings?.disabled_gmail_skills ?? []); + setIsMultiUserMode(settingsRes?.MultiUserMode ?? false); + + if (statusRes?.success && statusRes.config) { + const loadedDeploymentId = statusRes.config.deploymentId || ""; + const loadedApiKey = statusRes.config.apiKey || ""; + setDeploymentId(loadedDeploymentId); + setApiKey(loadedApiKey); + setConfigDefaultExpanded(!(loadedDeploymentId && loadedApiKey)); + } + }) + .catch(() => { + setDisabledSkills([]); + setDeploymentId(""); + setApiKey(""); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (prevHasChanges.current === true && hasChanges === false) { + Promise.all([ + Admin.systemPreferencesByFields(["disabled_gmail_skills"]), + GoogleAgentSkills.gmail.getStatus(), + ]) + .then(([prefsRes, statusRes]) => { + setDisabledSkills(prefsRes?.settings?.disabled_gmail_skills ?? []); + if (statusRes?.success && statusRes.config) { + setDeploymentId(statusRes.config.deploymentId || ""); + setApiKey(statusRes.config.apiKey || ""); + } + }) + .catch(() => {}); + } + prevHasChanges.current = hasChanges; + }, [hasChanges]); + + function toggleGmailSkill(skillName) { + setHasChanges(true); + setDisabledSkills((prev) => + prev.includes(skillName) + ? prev.filter((s) => s !== skillName) + : [...prev, skillName] + ); + } + + const isConfigured = deploymentId && apiKey; + + return ( +
+
+
+
+ GMail + +
+ toggleSkill(skill)} + /> +
+ + {isMultiUserMode && ( +
+ +

+ {t("agent.skill.gmail.multiUserWarning")} +

+
+ )} + +

+ + ), + }} + /> +

+ + {enabled && !isMultiUserMode && ( + <> + + + {loading ? ( +
+ +
+ ) : ( + <> + + + {isConfigured && ( + + )} + + )} + + )} +
+
+ ); +} + +function ConfigurationSection({ + deploymentId, + setDeploymentId, + apiKey, + setApiKey, + setHasChanges, + isConfigured, + defaultExpanded = true, +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+ + + {expanded && ( +
+
+
+ + + + {t("agent.skill.gmail.deploymentIdHelp")} + +
+ { + setDeploymentId(e.target.value); + setHasChanges(true); + }} + placeholder="AKfycb..." + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ +
+
+ + + + {t("agent.skill.gmail.apiKeyHelp")} + +
+ { + setApiKey(e.target.value); + setHasChanges(true); + }} + placeholder="Your API key..." + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ {!isConfigured && ( +
+ +

+ {t("agent.skill.gmail.configurationRequired")} +

+
+ )} +
+ )} +
+ ); +} + +function SkillSearchInput({ onSearch }) { + const { t } = useTranslation(); + const inputRef = useRef(null); + + const debouncedSearch = useMemo( + () => + debounce((value) => { + onSearch(value); + }, 300), + [onSearch] + ); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + const handleChange = (e) => { + debouncedSearch(e.target.value); + }; + + return ( +
+ + +
+ ); +} + +function SkillsSection({ skillCategories, disabledSkills, onToggle }) { + const { t } = useTranslation(); + const [searchTerm, setSearchTerm] = useState(""); + + const handleSearch = useCallback((value) => { + setSearchTerm(value); + }, []); + + const filteredCategories = useMemo( + () => filterSkillCategories(skillCategories, searchTerm), + [skillCategories, searchTerm] + ); + + const hasResults = Object.keys(filteredCategories).length > 0; + + return ( +
+ + {hasResults ? ( +
+ {Object.entries(filteredCategories).map(([categoryKey, category]) => ( + + ))} +
+ ) : ( +

+ {t("agent.skill.gmail.noSkillsFound")} +

+ )} +
+ ); +} + +function CategorySection({ category, disabledSkills, onToggle }) { + const Icon = category.icon; + + return ( +
+
+ + + {category.title} + +
+
+ {category.skills.map((skill) => ( + onToggle(skill.name)} + /> + ))} +
+
+ ); +} + +function SkillRow({ skill, disabled, onToggle }) { + return ( +
+
+ + {skill.title} + + + {skill.description} + +
+ +
+ ); +} + +function HiddenFormInputs({ disabledSkills, deploymentId, apiKey }) { + const configJson = JSON.stringify({ + deploymentId: deploymentId || "", + apiKey: apiKey || "", + }); + + return ( + <> + + + + ); +} diff --git a/frontend/src/pages/Admin/Agents/GMailSkillPanel/utils.js b/frontend/src/pages/Admin/Agents/GMailSkillPanel/utils.js new file mode 100644 index 000000000..951152783 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/GMailSkillPanel/utils.js @@ -0,0 +1,136 @@ +import { + MagnifyingGlass, + EnvelopeOpen, + PaperPlaneTilt, + ChartBar, + PencilSimple, +} from "@phosphor-icons/react"; +export { filterSkillCategories } from "../utils"; + +export const getGmailSkills = (t) => ({ + search: { + title: t("agent.skill.gmail.categories.search.title"), + description: t("agent.skill.gmail.categories.search.description"), + icon: MagnifyingGlass, + skills: [ + { + name: "gmail-get-inbox", + title: t("agent.skill.gmail.skills.getInbox.title"), + description: t("agent.skill.gmail.skills.getInbox.description"), + }, + { + name: "gmail-search", + title: t("agent.skill.gmail.skills.search.title"), + description: t("agent.skill.gmail.skills.search.description"), + }, + { + name: "gmail-read-thread", + title: t("agent.skill.gmail.skills.readThread.title"), + description: t("agent.skill.gmail.skills.readThread.description"), + }, + ], + }, + drafts: { + title: t("agent.skill.gmail.categories.drafts.title"), + description: t("agent.skill.gmail.categories.drafts.description"), + icon: PencilSimple, + skills: [ + { + name: "gmail-create-draft", + title: t("agent.skill.gmail.skills.createDraft.title"), + description: t("agent.skill.gmail.skills.createDraft.description"), + }, + { + name: "gmail-create-draft-reply", + title: t("agent.skill.gmail.skills.createDraftReply.title"), + description: t("agent.skill.gmail.skills.createDraftReply.description"), + }, + { + name: "gmail-update-draft", + title: t("agent.skill.gmail.skills.updateDraft.title"), + description: t("agent.skill.gmail.skills.updateDraft.description"), + }, + { + name: "gmail-get-draft", + title: t("agent.skill.gmail.skills.getDraft.title"), + description: t("agent.skill.gmail.skills.getDraft.description"), + }, + { + name: "gmail-list-drafts", + title: t("agent.skill.gmail.skills.listDrafts.title"), + description: t("agent.skill.gmail.skills.listDrafts.description"), + }, + { + name: "gmail-delete-draft", + title: t("agent.skill.gmail.skills.deleteDraft.title"), + description: t("agent.skill.gmail.skills.deleteDraft.description"), + }, + { + name: "gmail-send-draft", + title: t("agent.skill.gmail.skills.sendDraft.title"), + description: t("agent.skill.gmail.skills.sendDraft.description"), + }, + ], + }, + send: { + title: t("agent.skill.gmail.categories.send.title"), + description: t("agent.skill.gmail.categories.send.description"), + icon: PaperPlaneTilt, + skills: [ + { + name: "gmail-send-email", + title: t("agent.skill.gmail.skills.sendEmail.title"), + description: t("agent.skill.gmail.skills.sendEmail.description"), + }, + { + name: "gmail-reply-to-thread", + title: t("agent.skill.gmail.skills.replyToThread.title"), + description: t("agent.skill.gmail.skills.replyToThread.description"), + }, + ], + }, + threads: { + title: t("agent.skill.gmail.categories.threads.title"), + description: t("agent.skill.gmail.categories.threads.description"), + icon: EnvelopeOpen, + skills: [ + { + name: "gmail-mark-read", + title: t("agent.skill.gmail.skills.markRead.title"), + description: t("agent.skill.gmail.skills.markRead.description"), + }, + { + name: "gmail-mark-unread", + title: t("agent.skill.gmail.skills.markUnread.title"), + description: t("agent.skill.gmail.skills.markUnread.description"), + }, + { + name: "gmail-move-to-trash", + title: t("agent.skill.gmail.skills.moveToTrash.title"), + description: t("agent.skill.gmail.skills.moveToTrash.description"), + }, + { + name: "gmail-move-to-archive", + title: t("agent.skill.gmail.skills.moveToArchive.title"), + description: t("agent.skill.gmail.skills.moveToArchive.description"), + }, + { + name: "gmail-move-to-inbox", + title: t("agent.skill.gmail.skills.moveToInbox.title"), + description: t("agent.skill.gmail.skills.moveToInbox.description"), + }, + ], + }, + account: { + title: t("agent.skill.gmail.categories.account.title"), + description: t("agent.skill.gmail.categories.account.description"), + icon: ChartBar, + skills: [ + { + name: "gmail-get-mailbox-stats", + title: t("agent.skill.gmail.skills.getMailboxStats.title"), + description: t("agent.skill.gmail.skills.getMailboxStats.description"), + }, + ], + }, +}); diff --git a/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/google-calendar.png b/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/google-calendar.png new file mode 100644 index 0000000000000000000000000000000000000000..1b1a34ff8e9ab942a82f364e32be19b453fda22b GIT binary patch literal 12854 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 J3l;7^{vT4$6!8E6 literal 0 HcmV?d00001 diff --git a/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/index.jsx b/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/index.jsx new file mode 100644 index 000000000..73f5ac473 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/index.jsx @@ -0,0 +1,457 @@ +import React, { + useEffect, + useState, + useRef, + useMemo, + useCallback, +} from "react"; +import Toggle, { SimpleToggleSwitch } from "@/components/lib/Toggle"; +import { Trans, useTranslation } from "react-i18next"; +import debounce from "lodash.debounce"; +import { + MagnifyingGlass, + CircleNotch, + Warning, + CaretDown, + CheckCircle, + Info, +} from "@phosphor-icons/react"; +import Admin from "@/models/admin"; +import System from "@/models/system"; +import GoogleAgentSkills from "@/models/googleAgentSkills"; +import { getGoogleCalendarSkills, filterSkillCategories } from "./utils"; +import { Tooltip } from "react-tooltip"; +import { Link } from "react-router-dom"; +import paths from "@/utils/paths"; +import GoogleCalendarIcon from "./google-calendar.png"; + +export default function GoogleCalendarSkillPanel({ + title, + skill, + toggleSkill, + enabled = false, + disabled = false, + setHasChanges, + hasChanges = false, +}) { + const { t } = useTranslation(); + const [disabledSkills, setDisabledSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [deploymentId, setDeploymentId] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [isMultiUserMode, setIsMultiUserMode] = useState(false); + const [configDefaultExpanded, setConfigDefaultExpanded] = useState(true); + const prevHasChanges = useRef(hasChanges); + const skillCategories = getGoogleCalendarSkills(t); + + useEffect(() => { + setLoading(true); + Promise.all([ + Admin.systemPreferencesByFields(["disabled_google_calendar_skills"]), + System.keys(), + GoogleAgentSkills.calendar.getStatus(), + ]) + .then(([prefsRes, settingsRes, statusRes]) => { + setDisabledSkills( + prefsRes?.settings?.disabled_google_calendar_skills ?? [] + ); + setIsMultiUserMode(settingsRes?.MultiUserMode ?? false); + + if (statusRes?.success && statusRes.config) { + const loadedDeploymentId = statusRes.config.deploymentId || ""; + const loadedApiKey = statusRes.config.apiKey || ""; + setDeploymentId(loadedDeploymentId); + setApiKey(loadedApiKey); + setConfigDefaultExpanded(!(loadedDeploymentId && loadedApiKey)); + } + }) + .catch(() => { + setDisabledSkills([]); + setDeploymentId(""); + setApiKey(""); + }) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { + if (prevHasChanges.current === true && hasChanges === false) { + Promise.all([ + Admin.systemPreferencesByFields(["disabled_google_calendar_skills"]), + GoogleAgentSkills.calendar.getStatus(), + ]) + .then(([prefsRes, statusRes]) => { + setDisabledSkills( + prefsRes?.settings?.disabled_google_calendar_skills ?? [] + ); + if (statusRes?.success && statusRes.config) { + setDeploymentId(statusRes.config.deploymentId || ""); + setApiKey(statusRes.config.apiKey || ""); + } + }) + .catch(() => {}); + } + prevHasChanges.current = hasChanges; + }, [hasChanges]); + + function toggleGoogleCalendarSkill(skillName) { + setHasChanges(true); + setDisabledSkills((prev) => + prev.includes(skillName) + ? prev.filter((s) => s !== skillName) + : [...prev, skillName] + ); + } + + const isConfigured = deploymentId && apiKey; + + return ( +
+
+
+
+ Google Calendar + +
+ toggleSkill(skill)} + /> +
+ + {isMultiUserMode && ( +
+ +

+ {t("agent.skill.googleCalendar.multiUserWarning")} +

+
+ )} + +

+ + ), + }} + /> +

+ + {enabled && !isMultiUserMode && ( + <> + + + {loading ? ( +
+ +
+ ) : ( + <> + + + {isConfigured && ( + + )} + + )} + + )} +
+
+ ); +} + +function ConfigurationSection({ + deploymentId, + setDeploymentId, + apiKey, + setApiKey, + setHasChanges, + isConfigured, + defaultExpanded = true, +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(defaultExpanded); + + return ( +
+ + + {expanded && ( +
+
+
+ + + + {t("agent.skill.googleCalendar.deploymentIdHelp")} + +
+ { + setDeploymentId(e.target.value); + setHasChanges(true); + }} + placeholder="AKfycb..." + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ +
+
+ + + + {t("agent.skill.googleCalendar.apiKeyHelp")} + +
+ { + setApiKey(e.target.value); + setHasChanges(true); + }} + placeholder="Your API key..." + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ {!isConfigured && ( +
+ +

+ {t("agent.skill.googleCalendar.configurationRequired")} +

+
+ )} +
+ )} +
+ ); +} + +function SkillSearchInput({ onSearch }) { + const { t } = useTranslation(); + const inputRef = useRef(null); + + const debouncedSearch = useMemo( + () => + debounce((value) => { + onSearch(value); + }, 300), + [onSearch] + ); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + const handleChange = (e) => { + debouncedSearch(e.target.value); + }; + + return ( +
+ + +
+ ); +} + +function SkillsSection({ skillCategories, disabledSkills, onToggle }) { + const { t } = useTranslation(); + const [searchTerm, setSearchTerm] = useState(""); + + const handleSearch = useCallback((value) => { + setSearchTerm(value); + }, []); + + const filteredCategories = useMemo( + () => filterSkillCategories(skillCategories, searchTerm), + [skillCategories, searchTerm] + ); + + const hasResults = Object.keys(filteredCategories).length > 0; + + return ( +
+ + {hasResults ? ( +
+ {Object.entries(filteredCategories).map(([categoryKey, category]) => ( + + ))} +
+ ) : ( +

+ {t("agent.skill.googleCalendar.noSkillsFound")} +

+ )} +
+ ); +} + +function CategorySection({ category, disabledSkills, onToggle }) { + const Icon = category.icon; + + return ( +
+
+ + + {category.title} + +
+
+ {category.skills.map((skill) => ( + onToggle(skill.name)} + /> + ))} +
+
+ ); +} + +function SkillRow({ skill, disabled, onToggle }) { + return ( +
+
+ + {skill.title} + + + {skill.description} + +
+ +
+ ); +} + +function HiddenFormInputs({ disabledSkills, deploymentId, apiKey }) { + const configJson = JSON.stringify({ + deploymentId: deploymentId || "", + apiKey: apiKey || "", + }); + + return ( + <> + + + + ); +} diff --git a/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/utils.js b/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/utils.js new file mode 100644 index 000000000..10fac710a --- /dev/null +++ b/frontend/src/pages/Admin/Agents/GoogleCalendarSkillPanel/utils.js @@ -0,0 +1,114 @@ +import { + CalendarBlank, + CalendarCheck, + CalendarPlus, + UserCircleGear, +} from "@phosphor-icons/react"; +export { filterSkillCategories } from "../utils"; + +export const getGoogleCalendarSkills = (t) => ({ + calendars: { + title: t("agent.skill.googleCalendar.categories.calendars.title"), + description: t( + "agent.skill.googleCalendar.categories.calendars.description" + ), + icon: CalendarBlank, + skills: [ + { + name: "gcal-list-calendars", + title: t("agent.skill.googleCalendar.skills.listCalendars.title"), + description: t( + "agent.skill.googleCalendar.skills.listCalendars.description" + ), + }, + { + name: "gcal-get-calendar", + title: t("agent.skill.googleCalendar.skills.getCalendar.title"), + description: t( + "agent.skill.googleCalendar.skills.getCalendar.description" + ), + }, + ], + }, + readEvents: { + title: t("agent.skill.googleCalendar.categories.readEvents.title"), + description: t( + "agent.skill.googleCalendar.categories.readEvents.description" + ), + icon: CalendarCheck, + skills: [ + { + name: "gcal-get-event", + title: t("agent.skill.googleCalendar.skills.getEvent.title"), + description: t( + "agent.skill.googleCalendar.skills.getEvent.description" + ), + }, + { + name: "gcal-get-events-for-day", + title: t("agent.skill.googleCalendar.skills.getEventsForDay.title"), + description: t( + "agent.skill.googleCalendar.skills.getEventsForDay.description" + ), + }, + { + name: "gcal-get-events", + title: t("agent.skill.googleCalendar.skills.getEvents.title"), + description: t( + "agent.skill.googleCalendar.skills.getEvents.description" + ), + }, + { + name: "gcal-get-upcoming-events", + title: t("agent.skill.googleCalendar.skills.getUpcomingEvents.title"), + description: t( + "agent.skill.googleCalendar.skills.getUpcomingEvents.description" + ), + }, + ], + }, + writeEvents: { + title: t("agent.skill.googleCalendar.categories.writeEvents.title"), + description: t( + "agent.skill.googleCalendar.categories.writeEvents.description" + ), + icon: CalendarPlus, + skills: [ + { + name: "gcal-quick-add", + title: t("agent.skill.googleCalendar.skills.quickAdd.title"), + description: t( + "agent.skill.googleCalendar.skills.quickAdd.description" + ), + }, + { + name: "gcal-create-event", + title: t("agent.skill.googleCalendar.skills.createEvent.title"), + description: t( + "agent.skill.googleCalendar.skills.createEvent.description" + ), + }, + { + name: "gcal-update-event", + title: t("agent.skill.googleCalendar.skills.updateEvent.title"), + description: t( + "agent.skill.googleCalendar.skills.updateEvent.description" + ), + }, + ], + }, + rsvp: { + title: t("agent.skill.googleCalendar.categories.rsvp.title"), + description: t("agent.skill.googleCalendar.categories.rsvp.description"), + icon: UserCircleGear, + skills: [ + { + name: "gcal-set-my-status", + title: t("agent.skill.googleCalendar.skills.setMyStatus.title"), + description: t( + "agent.skill.googleCalendar.skills.setMyStatus.description" + ), + }, + ], + }, +}); diff --git a/frontend/src/pages/Admin/Agents/OutlookSkillPanel/index.jsx b/frontend/src/pages/Admin/Agents/OutlookSkillPanel/index.jsx new file mode 100644 index 000000000..c91daddf7 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/OutlookSkillPanel/index.jsx @@ -0,0 +1,667 @@ +import React, { + useEffect, + useState, + useRef, + useMemo, + useCallback, +} from "react"; +import Toggle, { SimpleToggleSwitch } from "@/components/lib/Toggle"; +import { Trans, useTranslation } from "react-i18next"; +import debounce from "lodash.debounce"; +import { + MagnifyingGlass, + CircleNotch, + Warning, + CaretDown, + CheckCircle, + Info, + ArrowSquareOut, + XCircle, +} from "@phosphor-icons/react"; +import Admin from "@/models/admin"; +import System from "@/models/system"; +import OutlookAgent from "@/models/outlookAgent"; +import { getOutlookSkills, filterSkillCategories } from "./utils"; +import OutlookIcon from "./outlook.png"; +import { Tooltip } from "react-tooltip"; +import { Link } from "react-router-dom"; +import paths from "@/utils/paths"; + +export default function OutlookSkillPanel({ + title, + skill, + toggleSkill, + enabled = false, + disabled = false, + setHasChanges, + hasChanges = false, +}) { + const { t } = useTranslation(); + const [disabledSkills, setDisabledSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [clientId, setClientId] = useState(""); + const [tenantId, setTenantId] = useState(""); + const [clientSecret, setClientSecret] = useState(""); + const [authType, setAuthType] = useState("common"); + const [isAuthenticated, setIsAuthenticated] = useState(false); + const [authLoading, setAuthLoading] = useState(false); + const [isMultiUserMode, setIsMultiUserMode] = useState(false); + const [configDefaultExpanded, setConfigDefaultExpanded] = useState(true); + const prevHasChanges = useRef(hasChanges); + const skillCategories = getOutlookSkills(t); + + const fetchStatus = async () => { + try { + const data = await OutlookAgent.getStatus(); + if (data.success) { + setIsAuthenticated(data.isAuthenticated); + // Load config from status endpoint + if (data.config) { + setClientId(data.config.clientId || ""); + setTenantId(data.config.tenantId || ""); + setClientSecret(data.config.clientSecret || ""); + setAuthType(data.config.authType || "common"); + setConfigDefaultExpanded( + !(data.config.clientId && data.config.clientSecret) + ); + } + } + } catch (e) { + console.error("Failed to fetch Outlook status:", e); + } + }; + + useEffect(() => { + setLoading(true); + Promise.all([ + Admin.systemPreferencesByFields(["disabled_outlook_skills"]), + System.keys(), + OutlookAgent.getStatus(), + ]) + .then(([prefsRes, settingsRes, statusRes]) => { + setDisabledSkills(prefsRes?.settings?.disabled_outlook_skills ?? []); + setIsMultiUserMode(settingsRes?.MultiUserMode ?? false); + + // Load config from status endpoint + if (statusRes?.success) { + setIsAuthenticated(statusRes.isAuthenticated); + if (statusRes.config) { + setClientId(statusRes.config.clientId || ""); + setTenantId(statusRes.config.tenantId || ""); + setClientSecret(statusRes.config.clientSecret || ""); + setAuthType(statusRes.config.authType || "common"); + setConfigDefaultExpanded( + !(statusRes.config.clientId && statusRes.config.clientSecret) + ); + } + } + }) + .catch(() => { + setDisabledSkills([]); + setClientId(""); + setTenantId(""); + setClientSecret(""); + setAuthType("common"); + }) + .finally(() => setLoading(false)); + + const urlParams = new URLSearchParams(window.location.search); + const outlookAuth = urlParams.get("outlook_auth"); + if (outlookAuth === "success") { + fetchStatus(); + window.history.replaceState({}, document.title, window.location.pathname); + } else if (outlookAuth === "error") { + const message = urlParams.get("message"); + console.error("Outlook auth error:", message); + window.history.replaceState({}, document.title, window.location.pathname); + } + }, []); + + useEffect(() => { + if (prevHasChanges.current === true && hasChanges === false) { + Promise.all([ + Admin.systemPreferencesByFields(["disabled_outlook_skills"]), + OutlookAgent.getStatus(), + ]) + .then(([prefsRes, statusRes]) => { + setDisabledSkills(prefsRes?.settings?.disabled_outlook_skills ?? []); + if (statusRes?.success) { + setIsAuthenticated(statusRes.isAuthenticated); + if (statusRes.config) { + setClientId(statusRes.config.clientId || ""); + setTenantId(statusRes.config.tenantId || ""); + setClientSecret(statusRes.config.clientSecret || ""); + setAuthType(statusRes.config.authType || "common"); + } + } + }) + .catch(() => {}); + } + prevHasChanges.current = hasChanges; + }, [hasChanges]); + + function toggleOutlookSkill(skillName) { + setHasChanges(true); + setDisabledSkills((prev) => + prev.includes(skillName) + ? prev.filter((s) => s !== skillName) + : [...prev, skillName] + ); + } + + const handleStartAuth = async () => { + setAuthLoading(true); + try { + const data = await OutlookAgent.saveCredentialsAndGetAuthUrl({ + clientId, + tenantId, + clientSecret, + authType, + }); + if (data.success && data.url) { + window.open(data.url, "_blank"); + } else { + console.error("Failed to get auth URL:", data.error); + } + } catch (e) { + console.error("Auth error:", e); + } finally { + setAuthLoading(false); + } + }; + + const handleRevokeAuth = async () => { + setAuthLoading(true); + try { + const data = await OutlookAgent.revokeAccess(); + if (data.success) { + setIsAuthenticated(false); + } + } catch (e) { + console.error("Revoke error:", e); + } finally { + setAuthLoading(false); + } + }; + + // For organization auth type, tenant ID is required; for others it's optional + const hasCredentials = + authType === "organization" + ? clientId && tenantId && clientSecret + : clientId && clientSecret; + const isConfigured = hasCredentials && isAuthenticated; + + return ( +
+
+
+
+ Outlook + +
+ toggleSkill(skill)} + /> +
+ + {isMultiUserMode && ( +
+ +

+ {t("agent.skill.outlook.multiUserWarning")} +

+
+ )} + +

+ + ), + }} + /> +

+ + {enabled && !isMultiUserMode && ( + <> + + + {loading ? ( +
+ +
+ ) : ( + <> + + + {isConfigured && ( + + )} + + )} + + )} +
+
+ ); +} + +function ConfigurationSection({ + clientId, + setClientId, + tenantId, + setTenantId, + clientSecret, + setClientSecret, + authType, + setAuthType, + setHasChanges, + hasCredentials, + isAuthenticated, + isConfigured, + defaultExpanded = true, + onStartAuth, + onRevokeAuth, + authLoading, +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(defaultExpanded); + const showTenantId = authType === "organization"; + + return ( +
+ + + {expanded && ( +
+
+
+ + + + {t("agent.skill.outlook.authTypeHelp")} + +
+ +
+ +
+
+ + + + {t("agent.skill.outlook.clientIdHelp")} + +
+ { + setClientId(e.target.value); + setHasChanges(true); + }} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ + {showTenantId && ( +
+
+ + + + {t("agent.skill.outlook.tenantIdHelp")} + +
+ { + setTenantId(e.target.value); + setHasChanges(true); + }} + placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ )} + +
+
+ + + + {t("agent.skill.outlook.clientSecretHelp")} + +
+ { + setClientSecret(e.target.value); + setHasChanges(true); + }} + placeholder="Your client secret..." + className="w-full px-3 py-2 bg-theme-bg-primary border border-theme-sidebar-border rounded-lg text-theme-text-primary text-sm placeholder:text-theme-text-secondary/50" + /> +
+ + {!hasCredentials && ( +
+ +

+ {t("agent.skill.outlook.configurationRequired")} +

+
+ )} + + {hasCredentials && !isAuthenticated && ( +
+
+ +

+ {t("agent.skill.outlook.authRequired")} +

+
+ +
+ )} + + {hasCredentials && isAuthenticated && ( +
+
+ +

+ {t("agent.skill.outlook.authenticated")} +

+
+ +
+ )} +
+ )} +
+ ); +} + +function SkillSearchInput({ onSearch }) { + const { t } = useTranslation(); + const inputRef = useRef(null); + + const debouncedSearch = useMemo( + () => + debounce((value) => { + onSearch(value); + }, 300), + [onSearch] + ); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + const handleChange = (e) => { + debouncedSearch(e.target.value); + }; + + return ( +
+ + +
+ ); +} + +function SkillsSection({ skillCategories, disabledSkills, onToggle }) { + const { t } = useTranslation(); + const [searchTerm, setSearchTerm] = useState(""); + + const handleSearch = useCallback((value) => { + setSearchTerm(value); + }, []); + + const filteredCategories = useMemo( + () => filterSkillCategories(skillCategories, searchTerm), + [skillCategories, searchTerm] + ); + + const hasResults = Object.keys(filteredCategories).length > 0; + + return ( +
+ + {hasResults ? ( +
+ {Object.entries(filteredCategories).map(([categoryKey, category]) => ( + + ))} +
+ ) : ( +

+ {t("agent.skill.outlook.noSkillsFound")} +

+ )} +
+ ); +} + +function CategorySection({ category, disabledSkills, onToggle }) { + const Icon = category.icon; + + return ( +
+
+ + + {category.title} + +
+
+ {category.skills.map((skill) => ( + onToggle(skill.name)} + /> + ))} +
+
+ ); +} + +function SkillRow({ skill, disabled, onToggle }) { + return ( +
+
+ + {skill.title} + + + {skill.description} + +
+ +
+ ); +} + +function HiddenFormInputs({ disabledSkills }) { + return ( + + ); +} diff --git a/frontend/src/pages/Admin/Agents/OutlookSkillPanel/outlook.png b/frontend/src/pages/Admin/Agents/OutlookSkillPanel/outlook.png new file mode 100644 index 0000000000000000000000000000000000000000..13008bdf274d19f8b031c944b619786bc386235d GIT binary patch literal 39393 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 ({ + search: { + title: t("agent.skill.outlook.categories.search.title"), + description: t("agent.skill.outlook.categories.search.description"), + icon: MagnifyingGlass, + skills: [ + { + name: "outlook-get-inbox", + title: t("agent.skill.outlook.skills.getInbox.title"), + description: t("agent.skill.outlook.skills.getInbox.description"), + }, + { + name: "outlook-search", + title: t("agent.skill.outlook.skills.search.title"), + description: t("agent.skill.outlook.skills.search.description"), + }, + { + name: "outlook-read-thread", + title: t("agent.skill.outlook.skills.readThread.title"), + description: t("agent.skill.outlook.skills.readThread.description"), + }, + ], + }, + drafts: { + title: t("agent.skill.outlook.categories.drafts.title"), + description: t("agent.skill.outlook.categories.drafts.description"), + icon: PencilSimple, + skills: [ + { + name: "outlook-create-draft", + title: t("agent.skill.outlook.skills.createDraft.title"), + description: t("agent.skill.outlook.skills.createDraft.description"), + }, + { + name: "outlook-update-draft", + title: t("agent.skill.outlook.skills.updateDraft.title"), + description: t("agent.skill.outlook.skills.updateDraft.description"), + }, + { + name: "outlook-list-drafts", + title: t("agent.skill.outlook.skills.listDrafts.title"), + description: t("agent.skill.outlook.skills.listDrafts.description"), + }, + { + name: "outlook-delete-draft", + title: t("agent.skill.outlook.skills.deleteDraft.title"), + description: t("agent.skill.outlook.skills.deleteDraft.description"), + }, + { + name: "outlook-send-draft", + title: t("agent.skill.outlook.skills.sendDraft.title"), + description: t("agent.skill.outlook.skills.sendDraft.description"), + }, + ], + }, + send: { + title: t("agent.skill.outlook.categories.send.title"), + description: t("agent.skill.outlook.categories.send.description"), + icon: PaperPlaneTilt, + skills: [ + { + name: "outlook-send-email", + title: t("agent.skill.outlook.skills.sendEmail.title"), + description: t("agent.skill.outlook.skills.sendEmail.description"), + }, + ], + }, + account: { + title: t("agent.skill.outlook.categories.account.title"), + description: t("agent.skill.outlook.categories.account.description"), + icon: ChartBar, + skills: [ + { + name: "outlook-get-mailbox-stats", + title: t("agent.skill.outlook.skills.getMailboxStats.title"), + description: t( + "agent.skill.outlook.skills.getMailboxStats.description" + ), + }, + ], + }, +}); diff --git a/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx b/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx index 6c35a4171..11a58edbd 100644 --- a/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx +++ b/frontend/src/pages/Admin/Agents/WebSearchSelection/index.jsx @@ -1,6 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; import Admin from "@/models/admin"; -import AnythingLLMIcon from "@/media/logo/anything-llm-icon.png"; import SerpApiIcon from "./icons/serpapi.png"; import SearchApiIcon from "./icons/searchapi.png"; import SerperDotDevIcon from "./icons/serper.png"; @@ -34,14 +33,6 @@ import { } from "./SearchProviderOptions"; const SEARCH_PROVIDERS = [ - { - name: "Please make a selection", - value: "none", - logo: AnythingLLMIcon, - options: () => , - description: - "Web search will be disabled until a provider and keys are provided.", - }, { name: "DuckDuckGo", value: "duckduckgo-engine", @@ -119,13 +110,6 @@ const SEARCH_PROVIDERS = [ options: (settings) => , description: "AI-powered web search using the Perplexity Search API.", }, - { - name: "Perplexity Search", - value: "perplexity-search", - logo: PerplexitySearchIcon, - options: (settings) => , - description: "AI-powered web search using the Perplexity Search API.", - }, ]; export default function AgentWebSearchSelection({ @@ -139,7 +123,7 @@ export default function AgentWebSearchSelection({ }) { const searchInputRef = useRef(null); const [filteredResults, setFilteredResults] = useState([]); - const [selectedProvider, setSelectedProvider] = useState("none"); + const [selectedProvider, setSelectedProvider] = useState("duckduckgo-engine"); const [searchQuery, setSearchQuery] = useState(""); const [searchMenuOpen, setSearchMenuOpen] = useState(false); @@ -169,9 +153,11 @@ export default function AgentWebSearchSelection({ useEffect(() => { Admin.systemPreferencesByFields(["agent_search_provider"]) .then((res) => - setSelectedProvider(res?.settings?.agent_search_provider ?? "none") + setSelectedProvider( + res?.settings?.agent_search_provider ?? "duckduckgo-engine" + ) ) - .catch(() => setSelectedProvider("none")); + .catch(() => setSelectedProvider("duckduckgo-engine")); }, []); const selectedSearchProviderObject = @@ -289,11 +275,9 @@ export default function AgentWebSearchSelection({ )}
- {selectedProvider !== "none" && ( -
- {selectedSearchProviderObject.options(settings)} -
- )} +
+ {selectedSearchProviderObject.options(settings)} +
diff --git a/frontend/src/pages/Admin/Agents/index.jsx b/frontend/src/pages/Admin/Agents/index.jsx index a598e1eb1..9e9b5a461 100644 --- a/frontend/src/pages/Admin/Agents/index.jsx +++ b/frontend/src/pages/Admin/Agents/index.jsx @@ -13,11 +13,16 @@ import { Robot, Hammer, FlowArrow, + Package, } from "@phosphor-icons/react"; import ContextualSaveBar from "@/components/ContextualSaveBar"; import { castToType } from "@/utils/types"; import { FullScreenLoader } from "@/components/Preloader"; -import { getDefaultSkills, getConfigurableSkills } from "./skills"; +import { + getDefaultSkills, + getConfigurableSkills, + getAppIntegrationSkills, +} from "./skills.jsx"; import { DefaultBadge } from "./Badges/default"; import ImportedSkillList from "./Imported/SkillList"; import ImportedSkillConfig from "./Imported/ImportedSkillConfig"; @@ -64,10 +69,30 @@ export default function AdminAgents() { useState(false); const defaultSkills = getDefaultSkills(t); - const configurableSkills = getConfigurableSkills(t, { + const allConfigurableSkills = getConfigurableSkills(t, { fileSystemAgentAvailable, createFilesAgentAvailable, }); + const allAppIntegrationSkills = getAppIntegrationSkills(t); + + // Filter skills based on mode restrictions + // singleUserOnly -> hidden in multi-user mode + // multiUserOnly -> hidden when NOT in multi-user mode + const isMultiUserMode = settings?.MultiUserMode ?? false; + const filterSkillsByMode = ([_, skillConfig]) => { + if (!skillConfig.mode) return true; + if (skillConfig.mode.includes("singleUserOnly") && isMultiUserMode) + return false; + if (skillConfig.mode.includes("multiUserOnly") && !isMultiUserMode) + return false; + return true; + }; + const configurableSkills = Object.fromEntries( + Object.entries(allConfigurableSkills).filter(filterSkillsByMode) + ); + const appIntegrationSkills = Object.fromEntries( + Object.entries(allAppIntegrationSkills).filter(filterSkillsByMode) + ); // Alert user if they try to leave the page with unsaved changes useEffect(() => { @@ -111,7 +136,7 @@ export default function AdminAgents() { _preferences.settings?.disabled_agent_skills ?? [] ); setImportedSkills(_preferences.settings?.imported_agent_skills ?? []); - setActiveFlowIds(_preferences.settings?.active_agent_flows ?? []); + setActiveFlowIds(flows.filter((f) => f.active).map((f) => f.uuid)); setAgentFlows(flows); setFileSystemAgentAvailable(fsAgentAvailable); setCreateFilesAgentAvailable(createFilesAvailable); @@ -217,6 +242,8 @@ export default function AdminAgents() { SelectedSkillComponent = ImportedSkillConfig; } else if (configurableSkills[selectedSkill]) { SelectedSkillComponent = configurableSkills[selectedSkill]?.component; + } else if (appIntegrationSkills[selectedSkill]) { + SelectedSkillComponent = appIntegrationSkills[selectedSkill]?.component; } else { SelectedSkillComponent = defaultSkills[selectedSkill]?.component; } @@ -367,6 +394,17 @@ export default function AdminAgents() { activeSkills={agentSkills} /> +
+ +

App Integrations

+
+ +

Custom Skills

@@ -385,6 +423,7 @@ export default function AdminAgents() { flows={agentFlows} selectedFlow={selectedFlow} handleClick={handleFlowClick} + activeFlowIds={activeFlowIds} />
-
+
{SelectedSkillComponent ? ( <> {selectedMcpServer ? ( @@ -468,7 +507,7 @@ export default function AdminAgents() { setHasChanges={setHasChanges} {...defaultSkills[selectedSkill]} /> - ) : ( + ) : configurableSkills?.[selectedSkill] ? ( // The selected skill is a configurable skill - show the configurable skill panel + ) : ( + // The selected skill is an app integration skill + )} )} @@ -564,6 +618,17 @@ export default function AdminAgents() { activeSkills={agentSkills} /> +
+ +

App Integrations

+
+ +

Custom Skills

@@ -601,6 +666,7 @@ export default function AdminAgents() { flows={agentFlows} selectedFlow={selectedFlow} handleClick={handleFlowClick} + activeFlowIds={activeFlowIds} /> -
+
{SelectedSkillComponent ? ( <> {selectedMcpServer ? ( @@ -663,7 +729,7 @@ export default function AdminAgents() { setHasChanges={setHasChanges} {...defaultSkills[selectedSkill]} /> - ) : ( + ) : configurableSkills?.[selectedSkill] ? ( // The selected skill is a configurable skill - show the configurable skill panel + ) : ( + // The selected skill is an app integration skill + )} )} @@ -723,6 +802,7 @@ function SkillList({ selectedSkill = null, handleClick = null, activeSkills = [], + Icon = null, }) { if (skills.length === 0) return null; @@ -749,7 +829,14 @@ function SkillList({ }`} onClick={() => handleClick?.(skill)} > -
{settings.title}
+
+ {settings.Icon ? ( + + ) : ( + Icon && + )} +
{settings.title}
+
{isDefault ? ( diff --git a/frontend/src/pages/Admin/Agents/skills.jsx b/frontend/src/pages/Admin/Agents/skills.jsx new file mode 100644 index 000000000..5b75f83d5 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/skills.jsx @@ -0,0 +1,137 @@ +import AgentWebSearchSelection from "./WebSearchSelection"; +import AgentSQLConnectorSelection from "./SQLConnectorSelection"; +import GenericSkillPanel from "./GenericSkillPanel"; +import DefaultSkillPanel from "./DefaultSkillPanel"; +import FileSystemSkillPanel from "./FileSystemSkillPanel"; +import CreateFileSkillPanel from "./CreateFileSkillPanel"; +import GMailSkillPanel from "./GMailSkillPanel"; +import GoogleCalendarSkillPanel from "./GoogleCalendarSkillPanel"; +import OutlookSkillPanel from "./OutlookSkillPanel"; +import { + Brain, + File, + Browser, + ChartBar, + FolderOpen, + FilePlus, +} from "@phosphor-icons/react"; +import RAGImage from "@/media/agents/rag-memory.png"; +import SummarizeImage from "@/media/agents/view-summarize.png"; +import ScrapeWebsitesImage from "@/media/agents/scrape-websites.png"; +import GenerateChartsImage from "@/media/agents/generate-charts.png"; +import GenerateSaveImages from "@/media/agents/generate-save-files.png"; +import FileSystemImage from "@/media/agents/file-system.png"; +import GMailIcon from "./GMailSkillPanel/gmail.png"; +import OutlookIcon from "./OutlookSkillPanel/outlook.png"; +import GoogleCalendarIcon from "./GoogleCalendarSkillPanel/google-calendar.png"; + +export const getDefaultSkills = (t) => ({ + "rag-memory": { + title: t("agent.skill.rag.title"), + description: t("agent.skill.rag.description"), + component: DefaultSkillPanel, + icon: Brain, + image: RAGImage, + skill: "rag-memory", + }, + "document-summarizer": { + title: t("agent.skill.view.title"), + description: t("agent.skill.view.description"), + component: DefaultSkillPanel, + icon: File, + image: SummarizeImage, + skill: "document-summarizer", + }, + "web-scraping": { + title: t("agent.skill.scrape.title"), + description: t("agent.skill.scrape.description"), + component: DefaultSkillPanel, + icon: Browser, + image: ScrapeWebsitesImage, + skill: "web-scraping", + }, +}); + +export const getConfigurableSkills = ( + t, + { fileSystemAgentAvailable = true, createFilesAgentAvailable = true } = {} +) => ({ + ...(fileSystemAgentAvailable && { + "filesystem-agent": { + title: t("agent.skill.filesystem.title"), + description: t("agent.skill.filesystem.description"), + component: FileSystemSkillPanel, + skill: "filesystem-agent", + icon: FolderOpen, + image: FileSystemImage, + }, + }), + ...(createFilesAgentAvailable && { + "create-files-agent": { + title: t("agent.skill.createFiles.title"), + description: t("agent.skill.createFiles.description"), + component: CreateFileSkillPanel, + skill: "create-files-agent", + icon: FilePlus, + image: GenerateSaveImages, + }, + }), + "create-chart": { + title: t("agent.skill.generate.title"), + description: t("agent.skill.generate.description"), + component: GenericSkillPanel, + skill: "create-chart", + icon: ChartBar, + image: GenerateChartsImage, + }, + "web-browsing": { + title: t("agent.skill.web.title"), + description: t("agent.skill.web.description"), + component: AgentWebSearchSelection, + skill: "web-browsing", + }, + "sql-agent": { + title: t("agent.skill.sql.title"), + description: t("agent.skill.sql.description"), + component: AgentSQLConnectorSelection, + skill: "sql-agent", + }, +}); + +export const getAppIntegrationSkills = (t) => ({ + "gmail-agent": { + title: t("agent.skill.gmail.title"), + description: t("agent.skill.gmail.description"), + component: GMailSkillPanel, + skill: "gmail-agent", + Icon: ({ size }) => ( + GMail + ), + mode: ["singleUserOnly"], + }, + "google-calendar-agent": { + title: t("agent.skill.googleCalendar.title"), + description: t("agent.skill.googleCalendar.description"), + component: GoogleCalendarSkillPanel, + skill: "google-calendar-agent", + Icon: ({ size }) => ( + Google Calendar + ), + mode: ["singleUserOnly"], + }, + "outlook-agent": { + title: t("agent.skill.outlook.title"), + description: t("agent.skill.outlook.description"), + component: OutlookSkillPanel, + skill: "outlook-agent", + Icon: ({ size }) => ( + Outlook + ), + mode: ["singleUserOnly"], + }, +}); diff --git a/frontend/src/pages/Admin/Agents/utils.js b/frontend/src/pages/Admin/Agents/utils.js new file mode 100644 index 000000000..a2c9a03d4 --- /dev/null +++ b/frontend/src/pages/Admin/Agents/utils.js @@ -0,0 +1,33 @@ +import strDistance from "js-levenshtein"; + +const LEVENSHTEIN_THRESHOLD = 3; + +function skillMatchesSearch(skill, searchTerm) { + if (!searchTerm) return true; + + const normalizedSearch = searchTerm.toLowerCase().trim(); + const titleLower = skill.title.toLowerCase(); + const descLower = skill.description.toLowerCase(); + + if (titleLower.includes(normalizedSearch)) return true; + if (descLower.includes(normalizedSearch)) return true; + if (strDistance(titleLower, normalizedSearch) <= LEVENSHTEIN_THRESHOLD) + return true; + + return false; +} + +export function filterSkillCategories(skillCategories, searchTerm) { + if (!searchTerm) return skillCategories; + + const filtered = {}; + for (const [key, category] of Object.entries(skillCategories)) { + const matchingSkills = category.skills.filter((skill) => + skillMatchesSearch(skill, searchTerm) + ); + if (matchingSkills.length > 0) { + filtered[key] = { ...category, skills: matchingSkills }; + } + } + return filtered; +} diff --git a/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx b/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx index 786c89ff5..eb11d3025 100644 --- a/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx +++ b/frontend/src/pages/GeneralSettings/ApiKeys/ApiKeyRow/index.jsx @@ -1,31 +1,25 @@ import { useEffect, useState } from "react"; import Admin from "@/models/admin"; -import showToast from "@/utils/toast"; import { Trash } from "@phosphor-icons/react"; import { userFromStorage } from "@/utils/request"; import System from "@/models/system"; +import { useTranslation } from "react-i18next"; export default function ApiKeyRow({ apiKey, removeApiKey }) { + const { t } = useTranslation(); const [copied, setCopied] = useState(false); const handleDelete = async () => { - if ( - !window.confirm( - `Are you sure you want to deactivate this api key?\nAfter you do this it will not longer be useable.\n\nThis action is irreversible.` - ) - ) - return false; + if (!window.confirm(t("api.row.deleteConfirm"))) return false; const user = userFromStorage(); const Model = !!user ? Admin : System; await Model.deleteApiKey(apiKey.id); - showToast("API Key permanently deleted", "info"); removeApiKey(apiKey.id); }; const copyApiKey = () => { if (!apiKey) return false; window.navigator.clipboard.writeText(apiKey.secret); - showToast("API Key copied to clipboard", "success"); setCopied(true); }; @@ -41,26 +35,37 @@ export default function ApiKeyRow({ apiKey, removeApiKey }) { return ( <> - - - {apiKey.secret} + + + {apiKey.name || t("api.row.unnamed")} - {apiKey.createdBy?.username || "--"} - {apiKey.createdAt} - - - + + + {apiKey.secret} + + + + {apiKey.createdBy?.username || "--"} + + + {new Date(apiKey.createdAt).toLocaleString()} + + +
+ + +
diff --git a/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx b/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx index 8e0248b55..220947fb1 100644 --- a/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx +++ b/frontend/src/pages/GeneralSettings/ApiKeys/NewApiKeyModal/index.jsx @@ -4,10 +4,12 @@ import Admin from "@/models/admin"; import paths from "@/utils/paths"; import { userFromStorage } from "@/utils/request"; import System from "@/models/system"; -import showToast from "@/utils/toast"; +import { useTranslation } from "react-i18next"; export default function NewApiKeyModal({ closeModal, onSuccess }) { + const { t } = useTranslation(); const [apiKey, setApiKey] = useState(null); + const [name, setName] = useState(""); const [error, setError] = useState(null); const [copied, setCopied] = useState(false); @@ -17,7 +19,9 @@ export default function NewApiKeyModal({ closeModal, onSuccess }) { const user = userFromStorage(); const Model = !!user ? Admin : System; - const { apiKey: newApiKey, error } = await Model.generateApiKey(); + const { apiKey: newApiKey, error } = await Model.generateApiKey({ + name, + }); if (!!newApiKey) { setApiKey(newApiKey); onSuccess(); @@ -29,9 +33,6 @@ export default function NewApiKeyModal({ closeModal, onSuccess }) { if (!apiKey) return false; window.navigator.clipboard.writeText(apiKey.secret); setCopied(true); - showToast("API key copied to clipboard", "success", { - clear: true, - }); }; useEffect(() => { @@ -50,7 +51,7 @@ export default function NewApiKeyModal({ closeModal, onSuccess }) {

- Create new API key + {t("api.modal.title")}

) : ( @@ -127,7 +148,7 @@ export default function NewApiKeyModal({ closeModal, onSuccess }) { type="button" className="transition-all duration-300 text-white hover:bg-zinc-700 px-4 py-2 rounded-lg text-sm" > - Close + {t("api.modal.close")} )}
diff --git a/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx b/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx index 9c2e3c2a6..5f235dfc3 100644 --- a/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx +++ b/frontend/src/pages/GeneralSettings/ApiKeys/index.jsx @@ -84,10 +84,13 @@ export default function AdminApiKeys() { containerClassName="flex w-full" /> ) : ( - +
+ {apiKeys.length === 0 ? ( - ) : ( diff --git a/frontend/src/utils/chat/agent.js b/frontend/src/utils/chat/agent.js index 9f3bb479d..8e33e0967 100644 --- a/frontend/src/utils/chat/agent.js +++ b/frontend/src/utils/chat/agent.js @@ -158,6 +158,13 @@ export default function handleSocketResponse(socket, event, setChatHistory) { ); } + if (type === "chatId") { + if (!data.content.chatId) return prev; + return prev.map((msg) => + msg.uuid === uuid ? { ...msg, chatId: data.content.chatId } : msg + ); + } + if (type === "textResponseChunk") { return prev .map((msg) => diff --git a/frontend/src/utils/chat/markdown.js b/frontend/src/utils/chat/markdown.js index f7affa189..f6748c80a 100644 --- a/frontend/src/utils/chat/markdown.js +++ b/frontend/src/utils/chat/markdown.js @@ -63,7 +63,7 @@ markdown.renderer.rules.strong_close = () => ""; markdown.renderer.rules.link_open = (tokens, idx) => { const token = tokens[idx]; const href = token.attrs.find((attr) => attr[0] === "href"); - return ``; + return ``; }; // Custom renderer for responsive images rendered in markdown @@ -73,7 +73,7 @@ markdown.renderer.rules.image = function (tokens, idx) { const src = token.attrs[srcIndex][1]; const alt = token.content || ""; - return `
${alt}
`; + return `
${HTMLEncode(alt)}
`; }; markdown.use(markdownItKatexPlugin); diff --git a/frontend/src/utils/constants.js b/frontend/src/utils/constants.js index f6bdc7b9e..b50621ada 100644 --- a/frontend/src/utils/constants.js +++ b/frontend/src/utils/constants.js @@ -67,6 +67,12 @@ export const LEMONADE_COMMON_URLS = [ "http://127.0.0.1:8000/live", "http://host.docker.internal:8000/live", "http://172.17.0.1:8000/live", + + // In Lemonade 10.1.0 the base port is 13305 + "http://localhost:13305/live", + "http://127.0.0.1:13305/live", + "http://host.docker.internal:13305/live", + "http://172.17.0.1:13305/live", ]; export function fullApiUrl() { diff --git a/package.json b/package.json index a3cc60465..ae2a0c449 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "anything-llm", - "version": "1.11.1", + "version": "1.12.1", "description": "The best solution for turning private documents into a chat bot using off-the-shelf tools and commercially viable AI technologies.", "main": "index.js", "type": "module", diff --git a/server/.env.example b/server/.env.example index 3d08207cd..fdad8cd3a 100644 --- a/server/.env.example +++ b/server/.env.example @@ -454,3 +454,7 @@ TTS_PROVIDER="native" # many tools/MCP servers enabled. # AGENT_SKILL_RERANKER_ENABLED="true" # AGENT_SKILL_RERANKER_TOP_N=15 # (optional) Number of top tools to keep after reranking (default: 15) + +# (optional) Comma-separated list of skills that are auto-approved. +# This will allow the skill to be invoked without user interaction. +# AGENT_AUTO_APPROVED_SKILLS=create-pdf-file,create-word-file diff --git a/server/endpoints/admin.js b/server/endpoints/admin.js index 430a3e320..e0fc1975a 100644 --- a/server/endpoints/admin.js +++ b/server/endpoints/admin.js @@ -409,6 +409,12 @@ function adminEndpoints(app) { case "disabled_create_files_skills": requestedSettings[label] = safeJsonParse(setting?.value, []); break; + case "disabled_gmail_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; + case "disabled_outlook_skills": + requestedSettings[label] = safeJsonParse(setting?.value, []); + break; case "imported_agent_skills": requestedSettings[label] = ImportedPlugin.listImportedPlugins(); break; @@ -503,10 +509,11 @@ function adminEndpoints(app) { async (request, response) => { try { const user = await userFromSession(request, response); - const { apiKey, error } = await ApiKey.create(user.id); + const { name = null } = reqBody(request); + const { apiKey, error } = await ApiKey.create(user.id, name); await EventLogs.logEvent( "api_key_created", - { createdBy: user?.username }, + { createdBy: user?.username, name: apiKey?.name }, user?.id ); return response.status(200).json({ diff --git a/server/endpoints/system.js b/server/endpoints/system.js index 02b238889..a1ebeff7d 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -985,16 +985,17 @@ function systemEndpoints(app) { app.post( "/system/generate-api-key", [validatedRequest], - async (_, response) => { + async (request, response) => { try { if (response.locals.multiUserMode) { return response.sendStatus(401).end(); } - const { apiKey, error } = await ApiKey.create(); + const { name = null } = reqBody(request); + const { apiKey, error } = await ApiKey.create(null, name); await EventLogs.logEvent( "api_key_created", - {}, + { name: apiKey?.name }, response?.locals?.user?.id ); return response.status(200).json({ diff --git a/server/endpoints/telegram.js b/server/endpoints/telegram.js index 88d07b383..7dc4cba1d 100644 --- a/server/endpoints/telegram.js +++ b/server/endpoints/telegram.js @@ -36,12 +36,19 @@ function telegramEndpoints(app) { null; const threadSlug = activeUser?.active_thread || null; + let thread = null; let workspace = await Workspace.get({ slug: workspaceSlug }); if (!workspace) { const available = await Workspace.where({}, 1); if (available.length) workspace = available[0]; } - const thread = await WorkspaceThread.get({ slug: threadSlug }); + + if (!threadSlug) { + const availableThreads = await WorkspaceThread.where({ + workspace_id: workspace.id, + }); + if (availableThreads.length) thread = availableThreads[0]; + } return response.status(200).json({ config: { @@ -157,7 +164,7 @@ function telegramEndpoints(app) { async (_request, response) => { try { const service = new TelegramBotService(); - service.stop(); + await service.stop(); await ExternalCommunicationConnector.delete("telegram"); await EventLogs.logEvent("telegram_bot_disconnected"); return response.status(200).json({ success: true }); diff --git a/server/endpoints/utils/googleAgentSkillEndpoints.js b/server/endpoints/utils/googleAgentSkillEndpoints.js new file mode 100644 index 000000000..c7b1186d0 --- /dev/null +++ b/server/endpoints/utils/googleAgentSkillEndpoints.js @@ -0,0 +1,70 @@ +const { + isSingleUserMode, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); +const { GmailBridge } = require("../../utils/agents/aibitat/plugins/gmail/lib"); +const { + GoogleCalendarBridge, +} = require("../../utils/agents/aibitat/plugins/google-calendar/lib"); + +function googleAgentSkillEndpoints(app) { + if (!app) return; + + app.get( + "/admin/agent-skills/gmail/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const config = await GmailBridge.getConfig(); + + const hasDeploymentId = !!config.deploymentId; + const hasApiKey = !!config.apiKey; + const isConfigured = hasDeploymentId && hasApiKey; + + const safeConfig = { + deploymentId: config.deploymentId || "", + apiKey: hasApiKey ? "********" : "", + }; + + return response.status(200).json({ + success: true, + isConfigured, + config: safeConfig, + }); + } catch (e) { + console.error("Gmail status error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.get( + "/admin/agent-skills/google-calendar/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const config = await GoogleCalendarBridge.getConfig(); + + const hasDeploymentId = !!config.deploymentId; + const hasApiKey = !!config.apiKey; + const isConfigured = hasDeploymentId && hasApiKey; + + const safeConfig = { + deploymentId: config.deploymentId || "", + apiKey: hasApiKey ? "********" : "", + }; + + return response.status(200).json({ + success: true, + isConfigured, + config: safeConfig, + }); + } catch (e) { + console.error("Google Calendar status error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); +} + +module.exports = { googleAgentSkillEndpoints }; diff --git a/server/endpoints/utils/outlookAgentUtils.js b/server/endpoints/utils/outlookAgentUtils.js new file mode 100644 index 000000000..5d4dae63b --- /dev/null +++ b/server/endpoints/utils/outlookAgentUtils.js @@ -0,0 +1,190 @@ +const { reqBody } = require("../../utils/http"); +const { + isSingleUserMode, +} = require("../../utils/middleware/multiUserProtected"); +const { validatedRequest } = require("../../utils/middleware/validatedRequest"); + +/** + * Constructs the OAuth redirect URI from the request headers. + * @param {Object} request - Express request object + * @returns {string} The redirect URI for OAuth callback + */ +function getOutlookRedirectUri(request) { + const protocol = request.headers["x-forwarded-proto"] || request.protocol; + const host = request.headers["x-forwarded-host"] || request.get("host"); + return `${protocol}://${host}/api/agent-skills/outlook/auth-callback`; +} + +function outlookAgentEndpoints(app) { + if (!app) return; + + app.post( + "/admin/agent-skills/outlook/auth-url", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { clientId, tenantId, clientSecret, authType } = reqBody(request); + + if (!clientId || !clientSecret) { + return response.status(400).json({ + success: false, + error: "Client ID and Client Secret are required.", + }); + } + + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const { AUTH_TYPES } = outlookLib; + const validAuthType = Object.values(AUTH_TYPES).includes(authType) + ? authType + : AUTH_TYPES.common; + + if (validAuthType === AUTH_TYPES.organization && !tenantId) { + return response.status(400).json({ + success: false, + error: + "Tenant ID is required for organization-only authentication.", + }); + } + + const existingConfig = await outlookLib.OutlookBridge.getConfig(); + const configUpdate = { + ...existingConfig, + clientId: clientId.trim(), + tenantId: tenantId?.trim() || "", + authType: validAuthType, + }; + + if (!/^\*+$/.test(clientSecret)) + configUpdate.clientSecret = clientSecret.trim(); + + // If auth type changed, clear tokens as they won't work with different authority + if ( + existingConfig.authType && + existingConfig.authType !== validAuthType + ) { + delete configUpdate.accessToken; + delete configUpdate.refreshToken; + delete configUpdate.tokenExpiry; + } + + await outlookLib.OutlookBridge.updateConfig(configUpdate); + outlookLib.reset(); + + const redirectUri = getOutlookRedirectUri(request); + const result = await outlookLib.getAuthUrl(redirectUri); + if (!result.success) { + return response + .status(400) + .json({ success: false, error: result.error }); + } + + return response.status(200).json({ success: true, url: result.url }); + } catch (e) { + console.error("Outlook auth URL error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.get( + "/agent-skills/outlook/auth-callback", + [validatedRequest, isSingleUserMode], + async (request, response) => { + try { + const { code, error, error_description } = request.query; + + if (error) { + console.error("Outlook OAuth error:", error, error_description); + return response.redirect( + `/?outlook_auth=error&message=${encodeURIComponent(error_description || error)}` + ); + } + + if (!code) { + return response.redirect( + "/?outlook_auth=error&message=No authorization code received" + ); + } + + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const redirectUri = getOutlookRedirectUri(request); + const result = await outlookLib.exchangeCodeForToken(code, redirectUri); + + const frontendUrl = + process.env.NODE_ENV === "development" ? "http://localhost:3000" : ""; + + if (!result.success) { + return response.redirect( + `${frontendUrl}/settings/agents?outlook_auth=error&message=${encodeURIComponent(result.error)}` + ); + } + + return response.redirect( + `${frontendUrl}/settings/agents?outlook_auth=success` + ); + } catch (e) { + console.error("Outlook OAuth callback error:", e); + response.redirect( + `/?outlook_auth=error&message=${encodeURIComponent(e.message)}` + ); + } + } + ); + + app.get( + "/admin/agent-skills/outlook/status", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const { AUTH_TYPES, normalizeTokenExpiry } = outlookLib; + const config = await outlookLib.OutlookBridge.getConfig(); + const isConfigured = await outlookLib.OutlookBridge.isToolAvailable(); + + const authType = config.authType || AUTH_TYPES.common; + let hasCredentials = !!(config.clientId && config.clientSecret); + if (authType === AUTH_TYPES.organization) { + hasCredentials = hasCredentials && !!config.tenantId; + } + + const safeConfig = { + clientId: config.clientId || "", + tenantId: config.tenantId || "", + clientSecret: config.clientSecret ? "********" : "", + authType: config.authType || AUTH_TYPES.common, + }; + + return response.status(200).json({ + success: true, + isConfigured, + hasCredentials, + isAuthenticated: !!config.accessToken, + tokenExpiry: normalizeTokenExpiry(config.tokenExpiry), + config: safeConfig, + }); + } catch (e) { + console.error("Outlook status error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + + app.post( + "/admin/agent-skills/outlook/revoke", + [validatedRequest, isSingleUserMode], + async (_request, response) => { + try { + const outlookLib = require("../../utils/agents/aibitat/plugins/outlook/lib"); + const { SystemSettings } = require("../../models/systemSettings"); + await SystemSettings.delete({ label: "outlook_agent_config" }); + outlookLib.reset(); + return response.status(200).json({ success: true }); + } catch (e) { + console.error("Outlook revoke error:", e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); +} + +module.exports = { outlookAgentEndpoints }; diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 836c5475f..16595b3c3 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -224,6 +224,28 @@ function workspaceEndpoints(app) { deletes, response.locals?.user?.id ); + + const { + isNativeEmbedder, + embedFiles, + } = require("../utils/EmbeddingWorkerManager"); + + if (isNativeEmbedder() && adds.length > 0) { + await embedFiles( + currWorkspace.slug, + adds, + currWorkspace.id, + response.locals?.user?.id ?? null + ); + const updatedWorkspace = await Workspace.get({ + id: currWorkspace.id, + }); + response + .status(200) + .json({ workspace: updatedWorkspace, message: null }); + return; + } + const { failedToEmbed = [], errors = [] } = await Document.addDocuments( currWorkspace, adds, @@ -1059,6 +1081,66 @@ function workspaceEndpoints(app) { } ); + // SSE endpoint for embedding progress + app.get( + "/workspace/:slug/embed-progress", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (request, response) => { + try { + const workspace = response.locals.workspace; + const { + addSSEConnection, + removeSSEConnection, + } = require("../utils/EmbeddingWorkerManager"); + + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("Content-Type", "text/event-stream"); + response.setHeader("Access-Control-Allow-Origin", "*"); + response.setHeader("Connection", "keep-alive"); + response.flushHeaders(); + addSSEConnection(workspace.slug, response); + request.on("close", () => { + removeSSEConnection(workspace.slug, response); + }); + } catch (e) { + console.error(e.message, e); + response.status(500).end(); + } + } + ); + + app.delete( + "/workspace/:slug/embed-queue", + [ + validatedRequest, + flexUserRoleValid([ROLES.admin, ROLES.manager]), + validWorkspaceSlug, + ], + async (request, response) => { + try { + const workspace = response.locals.workspace; + const { filename } = reqBody(request); + if (!filename) { + response + .status(400) + .json({ success: false, error: "Missing filename" }); + return; + } + + const { removeQueuedFile } = require("../utils/EmbeddingWorkerManager"); + const sent = removeQueuedFile(workspace.slug, filename); + response.status(200).json({ success: sent }); + } catch (e) { + console.error(e.message, e); + response.status(500).json({ success: false, error: e.message }); + } + } + ); + app.get( "/workspace/:slug/is-agent-command-available", [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], diff --git a/server/index.js b/server/index.js index 5d6b94da6..9cfdce0d2 100644 --- a/server/index.js +++ b/server/index.js @@ -35,6 +35,12 @@ const { mcpServersEndpoints } = require("./endpoints/mcpServers"); const { mobileEndpoints } = require("./endpoints/mobile"); const { webPushEndpoints } = require("./endpoints/webPush"); const { telegramEndpoints } = require("./endpoints/telegram"); +const { + outlookAgentEndpoints, +} = require("./endpoints/utils/outlookAgentUtils"); +const { + googleAgentSkillEndpoints, +} = require("./endpoints/utils/googleAgentSkillEndpoints"); const { httpLogger } = require("./middleware/httpLogger"); const app = express(); const apiRouter = express.Router(); @@ -89,6 +95,8 @@ mcpServersEndpoints(apiRouter); mobileEndpoints(apiRouter); webPushEndpoints(apiRouter); telegramEndpoints(apiRouter); +outlookAgentEndpoints(apiRouter); +googleAgentSkillEndpoints(apiRouter); // Externally facing embedder endpoints embeddedEndpoints(apiRouter); diff --git a/server/jobs/embedding-worker.js b/server/jobs/embedding-worker.js new file mode 100644 index 000000000..1e2071c45 --- /dev/null +++ b/server/jobs/embedding-worker.js @@ -0,0 +1,199 @@ +/** + * Embedding Worker + * + * Runs the full document-embedding loop in an isolated child process so that + * OOM from the native embedding model only kills this worker, not the main server. + * + * Spawned on-demand by EmbeddingWorkerManager via BackgroundService/Bree. + * Processes files sequentially and accepts additional files mid-run. + * + * IPC protocol (receives from parent): + * { type: "embed", files, workspaceSlug, workspaceId, userId } + * { type: "add_files", files } + * + * IPC protocol (sends to parent): + * { type: "batch_starting", workspaceSlug, filenames, totalDocs } + * { type: "doc_starting", workspaceSlug, filename, docIndex, totalDocs } + * { type: "chunk_progress", workspaceSlug, filename, chunksProcessed, totalChunks } + * { type: "doc_complete", workspaceSlug, filename, docIndex, totalDocs } + * { type: "doc_failed", workspaceSlug, filename, error } + * { type: "all_complete", workspaceSlug, embedded, failed } + */ + +const { v4: uuidv4 } = require("uuid"); +const prisma = require("../utils/prisma"); +const { getVectorDbClass } = require("../utils/helpers"); +const { fileData } = require("../utils/files"); +const { Telemetry } = require("../models/telemetry"); + +const queue = []; +const cancelled = new Set(); +let processing = false; +let workspaceSlug = null; +let workspaceId = null; +let userId = null; + +function emit(event) { + try { + process.send({ ...event, silent: true }); + } catch { + // Parent may have disconnected + } +} + +async function processQueue() { + if (processing || queue.length === 0) return; + processing = true; + + const VectorDb = getVectorDbClass(); + const batch = [...queue]; + queue.length = 0; + + emit({ + type: "batch_starting", + workspaceSlug, + userId, + filenames: batch, + totalDocs: batch.length, + }); + + Telemetry.sendTelemetry("documents_embedded_in_workspace").catch(() => {}); + const embedded = []; + const failedToEmbed = []; + + for (const [index, filePath] of batch.entries()) { + if (cancelled.has(filePath)) { + cancelled.delete(filePath); + continue; + } + + const docProgress = { + workspaceSlug, + userId, + filename: filePath, + docIndex: index, + totalDocs: batch.length, + }; + + const data = await fileData(filePath); + if (!data) { + emit({ + type: "doc_failed", + ...docProgress, + error: "Failed to load file data", + }); + failedToEmbed.push(filePath); + continue; + } + + const docId = uuidv4(); + const { pageContent: _pageContent, ...metadata } = data; + const newDoc = { + docId, + filename: filePath.split("/")[1], + docpath: filePath, + workspaceId, + metadata: JSON.stringify(metadata), + }; + + emit({ + type: "doc_starting", + ...docProgress, + }); + + // Set context so NativeEmbedder can send chunk_progress IPC messages + // enriched with workspace/file info (read via process.send in embedChunks). + global.__embeddingProgress = { workspaceSlug, filename: filePath, userId }; + + const { vectorized, error } = await VectorDb.addDocumentToNamespace( + workspaceSlug, + { ...data, docId }, + filePath + ); + + if (!vectorized) { + console.error("Failed to vectorize", metadata?.title || newDoc.filename); + failedToEmbed.push(metadata?.title || newDoc.filename); + emit({ + type: "doc_failed", + ...docProgress, + error: error || "Unknown error", + }); + continue; + } + + try { + await prisma.workspace_documents.create({ data: newDoc }); + embedded.push(filePath); + emit({ + type: "doc_complete", + ...docProgress, + }); + } catch (err) { + console.error(err.message); + emit({ + type: "doc_failed", + ...docProgress, + error: "Failed to save document record", + }); + } + } + + processing = false; + + // If new files were added while we were processing, recurse. + if (queue.length > 0) { + await processQueue(); + return; + } + + emit({ + type: "all_complete", + workspaceSlug, + userId, + totalDocs: batch.length, + embedded: embedded.length, + failed: failedToEmbed.length, + embeddedFiles: embedded, + failedFiles: failedToEmbed, + }); + process.exit(0); +} + +process.on("message", async (msg) => { + if (!msg || !msg.type) return; + + if (msg.type === "embed") { + workspaceSlug = msg.workspaceSlug; + workspaceId = msg.workspaceId; + userId = msg.userId; + queue.push(...msg.files); + processQueue().catch((err) => { + console.error("[embedding-worker] Fatal error:", err); + process.exit(1); + }); + } + + if (msg.type === "add_files") { + queue.push(...msg.files); + // If we're not currently processing (worker is idle between batches), + // kick off processing immediately. + if (!processing) { + processQueue().catch((err) => { + console.error("[embedding-worker] Fatal error:", err); + process.exit(1); + }); + } + } + + if (msg.type === "remove_file") { + const idx = queue.indexOf(msg.filename); + if (idx !== -1) queue.splice(idx, 1); + else cancelled.add(msg.filename); + emit({ + type: "file_removed", + workspaceSlug, + filename: msg.filename, + }); + } +}); diff --git a/server/models/apiKeys.js b/server/models/apiKeys.js index 32727fec8..856c73fe3 100644 --- a/server/models/apiKeys.js +++ b/server/models/apiKeys.js @@ -2,17 +2,20 @@ const prisma = require("../utils/prisma"); const ApiKey = { tablename: "api_keys", - writable: [], + writable: ["name"], makeSecret: () => { const uuidAPIKey = require("uuid-apikey"); return uuidAPIKey.create().apiKey; }, - create: async function (createdByUserId = null) { + create: async function (createdByUserId = null, name = null) { try { + const normalizedName = + typeof name === "string" && name.trim().length > 0 ? name.trim() : null; const apiKey = await prisma.api_keys.create({ data: { + name: normalizedName, secret: this.makeSecret(), createdBy: createdByUserId, }, diff --git a/server/models/documents.js b/server/models/documents.js index 800971957..69d8feaa4 100644 --- a/server/models/documents.js +++ b/server/models/documents.js @@ -84,13 +84,37 @@ const Document = { const VectorDb = getVectorDbClass(); if (additions.length === 0) return { failed: [], embedded: [] }; const { fileData } = require("../utils/files"); + const { emitProgress } = require("../utils/EmbeddingWorkerManager"); const embedded = []; const failedToEmbed = []; const errors = new Set(); - for (const path of additions) { + emitProgress(workspace.slug, { + type: "batch_starting", + workspaceSlug: workspace.slug, + userId, + filenames: additions, + totalDocs: additions.length, + }); + + for (const [index, path] of additions.entries()) { + const docProgress = { + workspaceSlug: workspace.slug, + userId, + filename: path, + docIndex: index, + totalDocs: additions.length, + }; + const data = await fileData(path); - if (!data) continue; + if (!data) { + emitProgress(workspace.slug, { + type: "doc_failed", + ...docProgress, + error: "Failed to load file data", + }); + continue; + } const docId = uuidv4(); const { pageContent: _pageContent, ...metadata } = data; @@ -102,6 +126,14 @@ const Document = { metadata: JSON.stringify(metadata), }; + emitProgress(workspace.slug, { type: "doc_starting", ...docProgress }); + + global.__embeddingProgress = { + workspaceSlug: workspace.slug, + filename: path, + userId, + }; + const { vectorized, error } = await VectorDb.addDocumentToNamespace( workspace.slug, { ...data, docId }, @@ -115,17 +147,42 @@ const Document = { ); failedToEmbed.push(metadata?.title || newDoc.filename); errors.add(error); + emitProgress(workspace.slug, { + type: "doc_failed", + ...docProgress, + error: error || "Unknown error", + }); continue; } try { await prisma.workspace_documents.create({ data: newDoc }); embedded.push(path); + emitProgress(workspace.slug, { + type: "doc_complete", + ...docProgress, + }); } catch (error) { console.error(error.message); + emitProgress(workspace.slug, { + type: "doc_failed", + ...docProgress, + error: "Failed to save document record", + }); } } + global.__embeddingProgress = null; + + emitProgress(workspace.slug, { + type: "all_complete", + workspaceSlug: workspace.slug, + userId, + totalDocs: additions.length, + embedded: embedded.length, + failed: failedToEmbed.length, + }); + await Telemetry.sendTelemetry("documents_embedded_in_workspace", { LLMSelection: process.env.LLM_PROVIDER || "openai", Embedder: process.env.EMBEDDING_ENGINE || "inherit", diff --git a/server/models/systemSettings.js b/server/models/systemSettings.js index 13ccb5cd0..c54a32133 100644 --- a/server/models/systemSettings.js +++ b/server/models/systemSettings.js @@ -18,6 +18,21 @@ function isNullOrNaN(value) { return isNaN(value); } +/** + * Merges a string field from source to target if it passes validation. + * @param {Object} target - The target object to merge into + * @param {Object} source - The source object to read from + * @param {string} fieldName - The field name to merge + * @param {Function|null} validator - Optional validator function that returns false to reject the value + */ +function mergeStringField(target, source, fieldName, validator = null) { + const value = source[fieldName]; + if (value && typeof value === "string" && value.trim()) { + if (validator && !validator(value)) return; + target[fieldName] = value.trim(); + } +} + const SystemSettings = { /** A default system prompt that is used when no other system prompt is set or available to the function caller. */ saneDefaultSystemPrompt: @@ -35,6 +50,12 @@ const SystemSettings = { "disabled_agent_skills", "disabled_filesystem_skills", "disabled_create_files_skills", + "disabled_gmail_skills", + "gmail_agent_config", + "disabled_google_calendar_skills", + "google_calendar_agent_config", + "disabled_outlook_skills", + "outlook_agent_config", "imported_agent_skills", "custom_app_name", "feature_flags", @@ -54,6 +75,12 @@ const SystemSettings = { "disabled_agent_skills", "disabled_filesystem_skills", "disabled_create_files_skills", + "disabled_gmail_skills", + "gmail_agent_config", + "disabled_google_calendar_skills", + "google_calendar_agent_config", + "disabled_outlook_skills", + "outlook_agent_config", "agent_sql_connections", "custom_app_name", "default_system_prompt", @@ -174,6 +201,138 @@ const SystemSettings = { return JSON.stringify([]); } }, + disabled_gmail_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled gmail skills.`); + return JSON.stringify([]); + } + }, + gmail_agent_config: async (update) => { + const GmailBridge = require("../utils/agents/aibitat/plugins/gmail/lib"); + try { + if (!update) return JSON.stringify({}); + + const newConfig = + typeof update === "string" ? safeJsonParse(update, {}) : update; + const existingConfig = safeJsonParse( + (await SystemSettings.get({ label: "gmail_agent_config" }))?.value, + {} + ); + + const mergedConfig = { ...existingConfig }; + + mergeStringField(mergedConfig, newConfig, "deploymentId"); + mergeStringField( + mergedConfig, + newConfig, + "apiKey", + (v) => !v.match(/^\*+$/) + ); + + return JSON.stringify(mergedConfig); + } catch (e) { + console.error(`Could not validate gmail agent config:`, e.message); + return JSON.stringify({}); + } finally { + GmailBridge.reset(); + } + }, + disabled_google_calendar_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled google calendar skills.`); + return JSON.stringify([]); + } + }, + google_calendar_agent_config: async (update) => { + const GoogleCalendarBridge = require("../utils/agents/aibitat/plugins/google-calendar/lib"); + try { + if (!update) return JSON.stringify({}); + + const newConfig = + typeof update === "string" ? safeJsonParse(update, {}) : update; + const existingConfig = safeJsonParse( + (await SystemSettings.get({ label: "google_calendar_agent_config" })) + ?.value, + {} + ); + + const mergedConfig = { ...existingConfig }; + + mergeStringField(mergedConfig, newConfig, "deploymentId"); + mergeStringField( + mergedConfig, + newConfig, + "apiKey", + (v) => !v.match(/^\*+$/) + ); + + return JSON.stringify(mergedConfig); + } catch (e) { + console.error( + `Could not validate google calendar agent config:`, + e.message + ); + return JSON.stringify({}); + } finally { + GoogleCalendarBridge.reset(); + } + }, + disabled_outlook_skills: (updates) => { + try { + const skills = updates.split(",").filter((skill) => !!skill); + return JSON.stringify(skills); + } catch { + console.error(`Could not validate disabled outlook skills.`); + return JSON.stringify([]); + } + }, + outlook_agent_config: async (update) => { + const OutlookBridge = require("../utils/agents/aibitat/plugins/outlook/lib"); + try { + if (!update) return JSON.stringify({}); + + const newConfig = + typeof update === "string" ? safeJsonParse(update, {}) : update; + const existingConfig = safeJsonParse( + (await SystemSettings.get({ label: "outlook_agent_config" }))?.value, + {} + ); + + const mergedConfig = { ...existingConfig }; + + mergeStringField(mergedConfig, newConfig, "clientId"); + mergeStringField(mergedConfig, newConfig, "tenantId"); + mergeStringField( + mergedConfig, + newConfig, + "clientSecret", + (v) => !v.match(/^\*+$/) + ); + + if (newConfig.accessToken !== undefined) { + mergedConfig.accessToken = newConfig.accessToken; + } + if (newConfig.refreshToken !== undefined) { + mergedConfig.refreshToken = newConfig.refreshToken; + } + if (newConfig.tokenExpiry !== undefined) { + mergedConfig.tokenExpiry = newConfig.tokenExpiry; + } + + return JSON.stringify(mergedConfig); + } catch (e) { + console.error(`Could not validate outlook agent config:`, e.message); + return JSON.stringify({}); + } finally { + OutlookBridge.reset(); + } + }, agent_sql_connections: async (updates) => { const existingConnections = safeJsonParse( (await SystemSettings.get({ label: "agent_sql_connections" }))?.value, @@ -401,6 +560,18 @@ const SystemSettings = { return this._updateSettings(updates); }, + delete: async function (clause = {}) { + try { + if (!Object.keys(clause).length) + throw new Error("Clause cannot be empty"); + await prisma.system_settings.deleteMany({ where: clause }); + return true; + } catch (error) { + console.error(error.message); + return false; + } + }, + // Explicit update of settings + key validations. // Only use this method when directly setting a key value // that takes no user input for the keys being modified. @@ -417,6 +588,8 @@ const SystemSettings = { } } + if (validatedValue === undefined) continue; + updatePromises.push( prisma.system_settings.upsert({ where: { label: key }, diff --git a/server/models/workspaceChats.js b/server/models/workspaceChats.js index e48807be7..494c761f7 100644 --- a/server/models/workspaceChats.js +++ b/server/models/workspaceChats.js @@ -315,6 +315,45 @@ const WorkspaceChats = { return { chats: null, message: error.message }; } }, + upsert: async function ( + chatId = null, + data = { + workspaceId: null, + prompt: null, + response: {}, + user: null, + threadId: null, + include: true, + apiSessionId: null, + } + ) { + try { + const payload = { + workspaceId: data.workspaceId, + response: safeJSONStringify(data.response), + user_id: data.user?.id || null, + thread_id: data.threadId, + api_session_id: data.apiSessionId, + include: data.include, + }; + + const { chat } = await prisma.workspace_chats.upsert({ + where: { + id: Number(chatId), + user_id: data.user?.id || null, + }, + // On updates, we already have the prompt so we don't need to set it again. + update: { ...payload, lastUpdatedAt: new Date() }, + + // On creates, we need to set the prompt or else record will fail. + create: { ...payload, prompt: data.prompt }, + }); + return { chat, message: null }; + } catch (error) { + console.error(error.message); + return { chat: null, message: error.message }; + } + }, }; module.exports = { WorkspaceChats }; diff --git a/server/package.json b/server/package.json index f99339621..4d9ace484 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "anything-llm-server", - "version": "1.11.1", + "version": "1.12.1", "description": "Server endpoints to process or create content for chatting", "main": "index.js", "author": "Timothy Carambat (Mintplex Labs)", @@ -35,6 +35,7 @@ "@mdpdf/mdpdf": "0.1.4", "@mintplex-labs/bree": "^9.2.5", "@mintplex-labs/express-ws": "^5.0.7", + "@mintplex-labs/mdpdf": "^0.1.9", "@modelcontextprotocol/sdk": "^1.24.3", "@pinecone-database/pinecone": "^2.0.1", "@prisma/client": "5.3.1", @@ -69,6 +70,7 @@ "joi": "^17.11.0", "joi-password-complexity": "^5.2.0", "js-tiktoken": "^1.0.8", + "jsdom": "26.1.0", "jsonrepair": "^3.7.0", "jsonwebtoken": "^9.0.0", "jsdom": "26.1.0", diff --git a/server/prisma/migrations/20260406120000_init/migration.sql b/server/prisma/migrations/20260406120000_init/migration.sql new file mode 100644 index 000000000..2ade09e19 --- /dev/null +++ b/server/prisma/migrations/20260406120000_init/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "api_keys" ADD COLUMN "name" TEXT; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 39cefc72b..ace6aa5ca 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -13,6 +13,7 @@ datasource db { model api_keys { id Int @id @default(autoincrement()) + name String? secret String? @unique createdBy Int? createdAt DateTime @default(now()) diff --git a/server/utils/AiProviders/bedrock/index.js b/server/utils/AiProviders/bedrock/index.js index 2ac112bf7..4338e7caa 100644 --- a/server/utils/AiProviders/bedrock/index.js +++ b/server/utils/AiProviders/bedrock/index.js @@ -35,6 +35,15 @@ class AWSBedrockLLM { // Add other models here if identified ]; + /** + * List of Bedrock models observed to not support the `temperature` inference parameter. + * @type {string[]} + */ + noTemperatureModels = [ + "anthropic.claude-opus-4-7", + // Add other models here if identified + ]; + /** * Initializes the AWS Bedrock LLM connector. * @param {object | null} [embedder=null] - An optional embedder instance. Defaults to NativeEmbedder. @@ -103,6 +112,21 @@ class AWSBedrockLLM { return createBedrockCredentials(this.authMethod); } + /** + * Gets the temperature configuration for the AWS Bedrock LLM. + * @param {number} temperature - The temperature to use. + * @returns {{temperature: number}} The temperature configuration object with the temperature value as a float. + */ + temperatureConfig(temperature = this.defaultTemp) { + if (typeof temperature !== "number") return {}; + + // So model names prefix `us.` and may not be exact matches - so we check with includes to see if the model + // substring matches any of the models in the noTemperatureModels array. + if (this.noTemperatureModels.some((model) => this.model.includes(model))) + return {}; + return { temperature: parseFloat(temperature) }; + } + /** * Gets the configured AWS authentication method ('iam' or 'sessionToken'). * Defaults to 'iam' if the environment variable is invalid. @@ -408,7 +432,7 @@ class AWSBedrockLLM { messages: history, inferenceConfig: { maxTokens: maxTokensToSend, - temperature: temperature ?? this.defaultTemp, + ...this.temperatureConfig(temperature), }, system: systemBlock, }) @@ -483,7 +507,7 @@ class AWSBedrockLLM { messages: history, inferenceConfig: { maxTokens: maxTokensToSend, - temperature: temperature ?? this.defaultTemp, + ...this.temperatureConfig(temperature), }, system: systemBlock, }) diff --git a/server/utils/AiProviders/genericOpenAi/index.js b/server/utils/AiProviders/genericOpenAi/index.js index 4e6de97e9..1865e9e26 100644 --- a/server/utils/AiProviders/genericOpenAi/index.js +++ b/server/utils/AiProviders/genericOpenAi/index.js @@ -208,6 +208,22 @@ class GenericOpenAiLLM { return textResponse; } + /** + * Includes the usage in the response if the ENV flag is set + * using the stream_options: { include_usage: true } option. This is available via ENV + * because some providers will crash with invalid options. + * @returns {Object} + */ + #includeStreamOptionsUsage() { + if (!("GENERIC_OPEN_AI_REPORT_USAGE" in process.env)) return {}; + if (process.env.GENERIC_OPEN_AI_REPORT_USAGE !== "true") return {}; + return { + stream_options: { + include_usage: true, + }, + }; + } + async getChatCompletion(messages = null, { temperature = 0.7 }) { const result = await LLMPerformanceMonitor.measureAsyncFunction( this.openai.chat.completions @@ -256,6 +272,7 @@ class GenericOpenAiLLM { messages, temperature, max_tokens: this.maxTokens, + ...this.#includeStreamOptionsUsage(), }), messages, runPromptTokenCalculation: true, @@ -404,6 +421,50 @@ class GenericOpenAiLLM { }); } + /** + * Whether this provider supports native OpenAI-compatible tool calling. + * - This can be any OpenAI compatible provider that supports tool calling + * - We check the ENV to see if the provider supports tool calling. + * - If the ENV is not set, we default to false. + * @returns {boolean} + */ + #supportsCapabilityFromENV(capability = "") { + const CapabilityEnvMap = { + tools: "PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING", + reasoning: "PROVIDER_SUPPORTS_REASONING", + imageGeneration: "PROVIDER_SUPPORTS_IMAGE_GENERATION", + vision: "PROVIDER_SUPPORTS_VISION", + }; + + const envKey = CapabilityEnvMap[capability]; + if (!envKey) return false; + if (!(envKey in process.env)) return false; + return process.env[envKey]?.includes("generic-openai") || false; + } + + /** + * Returns the capabilities of the model. + * @returns {{tools: 'unknown' | boolean, reasoning: 'unknown' | boolean, imageGeneration: 'unknown' | boolean, vision: 'unknown' | boolean}} + */ + getModelCapabilities() { + try { + return { + tools: this.#supportsCapabilityFromENV("tools"), + reasoning: this.#supportsCapabilityFromENV("reasoning"), + imageGeneration: this.#supportsCapabilityFromENV("imageGeneration"), + vision: this.#supportsCapabilityFromENV("vision"), + }; + } catch (error) { + console.error("Error getting model capabilities:", error); + return { + tools: "unknown", + reasoning: "unknown", + imageGeneration: "unknown", + vision: "unknown", + }; + } + } + // Simple wrapper for dynamic embedder & normalize interface for all LLM implementations async embedTextInput(textInput) { return await this.embedder.embedTextInput(textInput); diff --git a/server/utils/AiProviders/lemonade/index.js b/server/utils/AiProviders/lemonade/index.js index 206b15823..b78953403 100644 --- a/server/utils/AiProviders/lemonade/index.js +++ b/server/utils/AiProviders/lemonade/index.js @@ -22,7 +22,7 @@ class LemonadeLLM { process.env.LEMONADE_LLM_BASE_PATH, "openai" ), - apiKey: process.env.LEMONADE_LLM_API_KEY ?? null, + apiKey: process.env.LEMONADE_LLM_API_KEY || null, }); this.model = modelPreference || process.env.LEMONADE_LLM_MODEL_PREF; @@ -202,7 +202,7 @@ class LemonadeLLM { process.env.LEMONADE_LLM_BASE_PATH, "openai" ), - apiKey: process.env.LEMONADE_LLM_API_KEY ?? null, + apiKey: process.env.LEMONADE_LLM_API_KEY || null, }); const { labels = [] } = await client.models.retrieve(this.model); @@ -223,6 +223,41 @@ class LemonadeLLM { } } + /** + * Get the currently loaded models from the Lemonade server. + * @returns {Promise} + */ + static async getCurrentlyLoadedModels() { + const endpoint = new URL( + parseLemonadeServerEndpoint(process.env.LEMONADE_LLM_BASE_PATH, "openai") + ); + endpoint.pathname += "/health"; + const loadedModels = await fetch(endpoint.toString(), { + method: "GET", + headers: { + ...(process.env.LEMONADE_LLM_API_KEY + ? { Authorization: `Bearer ${process.env.LEMONADE_LLM_API_KEY}` } + : {}), + }, + }) + .then((response) => { + if (!response.ok) + throw new Error( + `Failed to get currently loaded models: ${response.statusText}` + ); + return response.json(); + }) + .then(({ all_models_loaded = [] } = {}) => { + return all_models_loaded.map((model) => { + return { + model_name: model.model_name, + ctx_size: model?.recipe_options?.ctx_size ?? 8192, + }; + }); + }); + return loadedModels; + } + /** * Utility function to load a model from the Lemonade server. * Does not check if the model is already loaded or unloads any models. @@ -230,12 +265,33 @@ class LemonadeLLM { */ static async loadModel(model, basePath = process.env.LEMONADE_LLM_BASE_PATH) { try { + const desiredCtxSize = Number(this.promptWindowLimit()); + const currentlyLoadedModels = + await LemonadeLLM.getCurrentlyLoadedModels(); + const modelAlreadyLoaded = currentlyLoadedModels.find( + (m) => m.model_name === model + ); + + if (modelAlreadyLoaded) { + if (modelAlreadyLoaded.ctx_size === desiredCtxSize) { + LemonadeLLM.slog( + `Model ${model} already loaded with ctx size ${desiredCtxSize}` + ); + return true; + } + + LemonadeLLM.slog( + `Model ${model} needs to be reloaded again with ctx size ${desiredCtxSize}` + ); + } + const endpoint = new URL(parseLemonadeServerEndpoint(basePath, "openai")); endpoint.pathname += "/load"; LemonadeLLM.slog( - `Loading model ${model} with context size ${this.promptWindowLimit()}` + `Loading model ${model} with context size ${desiredCtxSize}` ); + await fetch(endpoint.toString(), { method: "POST", headers: { @@ -246,7 +302,7 @@ class LemonadeLLM { }, body: JSON.stringify({ model_name: String(model), - ctx_size: Number(this.promptWindowLimit()), + ctx_size: desiredCtxSize, }), }) .then((response) => { diff --git a/server/utils/BackgroundWorkers/index.js b/server/utils/BackgroundWorkers/index.js index 85062b2b8..d0f0099d7 100644 --- a/server/utils/BackgroundWorkers/index.js +++ b/server/utils/BackgroundWorkers/index.js @@ -45,6 +45,35 @@ class BackgroundService { console.log(`\x1b[36m[${this.name}]\x1b[0m ${text}`, ...args); } + /** + * Returns the root path where job files are located. + * Handles the difference between development and production (bundled) environments. + * @returns {string} + */ + get jobsRoot() { + return this.#root; + } + + /** + * Wraps the logger so that IPC messages carrying `silent: true` are + * suppressed. Bree unconditionally calls `logger.info(message)` for + * every IPC message from forked processes, so this is the only + * interception point. + */ + #makeBreeLogger() { + const base = this.logger; + const isSilent = (args) => args.length === 1 && args[0]?.silent === true; + + const wrapped = Object.create(base); + wrapped.info = (...args) => { + if (!isSilent(args)) base.info(...args); + }; + wrapped.log = (...args) => { + if (!isSilent(args)) base.log(...args); + }; + return wrapped; + } + async boot() { const { DocumentSyncQueue } = require("../../models/documentSyncQueue"); this.documentSyncEnabled = await DocumentSyncQueue.enabled(); @@ -52,7 +81,7 @@ class BackgroundService { this.#log("Starting..."); this.bree = new Bree({ - logger: this.logger, + logger: this.#makeBreeLogger(), root: this.#root, jobs: jobsToRun, errorHandler: this.onError, @@ -91,6 +120,7 @@ class BackgroundService { } onWorkerMessageHandler(message, _workerMetadata) { + if (message?.silent || message?.message?.silent) return; this.logger.info(`${message.message}`, { service: "bg-worker", origin: message.name, @@ -98,47 +128,40 @@ class BackgroundService { } /** - * Run a one-off job via Bree with a data payload sent over IPC. - * The job file receives the payload via process.on('message'). - * @param {string} name - Job filename (without .js) in the jobs directory - * @param {object} payload - Data to send to the job via IPC - * @param {object} [opts] - * @param {function} [opts.onMessage] - Callback for IPC messages from the child process - * @returns {Promise} Resolves when the job exits with code 0 + * Spawn a one-off Bree worker process for the given script. + * @param {string} scriptPath - Absolute path to the worker JS file + * @returns {Promise<{ worker: ChildProcess, jobId: string }>} */ - async runJob(name, payload = {}, { onMessage } = {}) { - const jobId = `${name}-${Date.now()}`; + async spawnWorker(scriptPath) { + if (!this.bree) + throw new Error("BackgroundService has not been booted yet"); + + const jobId = `${path.basename(scriptPath, ".js")}-${Date.now()}`; await this.bree.add({ name: jobId, - path: path.resolve(this.#root, `${name}.js`), + path: scriptPath, }); await this.bree.run(jobId); const worker = this.bree.workers.get(jobId); - if (worker && typeof worker.send === "function") { - worker.send(payload); - } - if (worker && onMessage) { - worker.on("message", onMessage); - } - return new Promise((resolve, reject) => { - worker.on("exit", async (code) => { - try { - await this.bree.remove(jobId); - } catch {} - if (code === 0) resolve(); - else reject(new Error(`Job ${jobId} exited with code ${code}`)); - }); + if (!worker) throw new Error("Failed to get worker reference from Bree"); - worker.on("error", async (err) => { - try { - await this.bree.remove(jobId); - } catch {} - reject(err); - }); - }); + return { worker, jobId }; + } + + /** + * Remove a one-off Bree job registration so stale entries don't accumulate. + * @param {string} jobId + */ + async removeJob(jobId) { + if (!jobId) return; + try { + if (this.bree) await this.bree.remove(jobId); + } catch { + /* Job may already be removed */ + } } } diff --git a/server/utils/EmbeddingEngines/azureOpenAi/index.js b/server/utils/EmbeddingEngines/azureOpenAi/index.js index 79fd000d1..084127c82 100644 --- a/server/utils/EmbeddingEngines/azureOpenAi/index.js +++ b/server/utils/EmbeddingEngines/azureOpenAi/index.js @@ -1,4 +1,4 @@ -const { toChunks } = require("../../helpers"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); class AzureOpenAiEmbedder { constructor() { @@ -48,6 +48,7 @@ class AzureOpenAiEmbedder { // we concurrently execute each max batch of text chunks possible. // Refer to constructor maxConcurrentChunks for more info. const embeddingRequests = []; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( new Promise((resolve) => { @@ -57,9 +58,13 @@ class AzureOpenAiEmbedder { input: chunk, }) .then((res) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: res.data, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingEngines/cohere/index.js b/server/utils/EmbeddingEngines/cohere/index.js index 0dfb61d0d..107d27c89 100644 --- a/server/utils/EmbeddingEngines/cohere/index.js +++ b/server/utils/EmbeddingEngines/cohere/index.js @@ -1,4 +1,4 @@ -const { toChunks } = require("../../helpers"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); class CohereEmbedder { constructor() { @@ -28,6 +28,7 @@ class CohereEmbedder { async embedChunks(textChunks = []) { const embeddingRequests = []; this.inputType = "search_document"; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( @@ -39,9 +40,13 @@ class CohereEmbedder { inputType: this.inputType, }) .then((res) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: res.embeddings, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingEngines/gemini/index.js b/server/utils/EmbeddingEngines/gemini/index.js index 410742e88..d87bd8635 100644 --- a/server/utils/EmbeddingEngines/gemini/index.js +++ b/server/utils/EmbeddingEngines/gemini/index.js @@ -1,4 +1,4 @@ -const { toChunks } = require("../../helpers"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); const MODEL_MAP = { "gemini-embedding-001": 2048, @@ -68,6 +68,7 @@ class GeminiEmbedder { // we concurrently execute each max batch of text chunks possible. // Refer to constructor maxConcurrentChunks for more info. const embeddingRequests = []; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( new Promise((resolve) => { @@ -78,9 +79,13 @@ class GeminiEmbedder { dimensions: this.outputDimensions, }) .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: result?.data, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingEngines/genericOpenAi/index.js b/server/utils/EmbeddingEngines/genericOpenAi/index.js index f8885f811..7addb155c 100644 --- a/server/utils/EmbeddingEngines/genericOpenAi/index.js +++ b/server/utils/EmbeddingEngines/genericOpenAi/index.js @@ -1,4 +1,8 @@ -const { toChunks, maximumChunkLength } = require("../../helpers"); +const { + toChunks, + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); class GenericOpenAiEmbedder { constructor() { @@ -108,6 +112,7 @@ class GenericOpenAiEmbedder { if (error) throw new Error(`GenericOpenAI Failed to embed: ${error.message}`); allResults.push(...(data || [])); + reportEmbeddingProgress(allResults.length, textChunks.length); if (this.apiRequestDelay) await this.runDelay(); } diff --git a/server/utils/EmbeddingEngines/lemonade/index.js b/server/utils/EmbeddingEngines/lemonade/index.js index 44c20c369..8d5a64f43 100644 --- a/server/utils/EmbeddingEngines/lemonade/index.js +++ b/server/utils/EmbeddingEngines/lemonade/index.js @@ -1,4 +1,5 @@ const { parseLemonadeServerEndpoint } = require("../../AiProviders/lemonade"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); class LemonadeEmbedder { constructor() { @@ -13,10 +14,11 @@ class LemonadeEmbedder { process.env.EMBEDDING_BASE_PATH, "openai" ), - apiKey: process.env.LEMONADE_LLM_API_KEY ?? null, + apiKey: process.env.LEMONADE_LLM_API_KEY || null, }); this.model = process.env.EMBEDDING_MODEL_PREF; + this.maxConcurrentChunks = 50; this.embeddingMaxChunkLength = process.env.EMBEDDING_MODEL_MAX_CHUNK_LENGTH || 8_191; } @@ -34,23 +36,37 @@ class LemonadeEmbedder { }); return response?.data[0]?.embedding || []; } catch (error) { - console.error("Failed to get embedding from Lemonade.", error.message); - return []; + this.log("Failed to get embedding from Lemonade.", error.message); + throw error; } } async embedChunks(textChunks = []) { - try { - this.log(`Embedding ${textChunks.length} chunks of text...`); - const response = await this.lemonade.embeddings.create({ - model: this.model, - input: textChunks, - }); - return response?.data?.map((emb) => emb.embedding) || []; - } catch (error) { - console.error("Failed to get embeddings from Lemonade.", error.message); - return new Array(textChunks.length).fill([]); + this.log( + `Embedding ${textChunks.length} chunks of text with ${this.model}.` + ); + + const allResults = []; + for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { + try { + const response = await this.lemonade.embeddings.create({ + model: this.model, + input: chunk, + }); + + const embeddings = response?.data?.map((emb) => emb.embedding) || []; + if (embeddings.length === 0) + throw new Error("Lemonade returned empty embeddings for batch"); + + allResults.push(...embeddings); + reportEmbeddingProgress(allResults.length, textChunks.length); + } catch (error) { + this.log("Failed to get embeddings from Lemonade.", error.message); + throw new Error(`Lemonade Failed to embed: ${error.message}`); + } } + + return allResults.length > 0 ? allResults : null; } } diff --git a/server/utils/EmbeddingEngines/liteLLM/index.js b/server/utils/EmbeddingEngines/liteLLM/index.js index cd22480b1..4c225fb95 100644 --- a/server/utils/EmbeddingEngines/liteLLM/index.js +++ b/server/utils/EmbeddingEngines/liteLLM/index.js @@ -1,4 +1,8 @@ -const { toChunks, maximumChunkLength } = require("../../helpers"); +const { + toChunks, + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); class LiteLLMEmbedder { constructor() { @@ -31,6 +35,7 @@ class LiteLLMEmbedder { // we concurrently execute each max batch of text chunks possible. // Refer to constructor maxConcurrentChunks for more info. const embeddingRequests = []; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( new Promise((resolve) => { @@ -40,9 +45,13 @@ class LiteLLMEmbedder { input: chunk, }) .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: result?.data, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingEngines/lmstudio/index.js b/server/utils/EmbeddingEngines/lmstudio/index.js index d1138aafd..239cc3ac5 100644 --- a/server/utils/EmbeddingEngines/lmstudio/index.js +++ b/server/utils/EmbeddingEngines/lmstudio/index.js @@ -1,5 +1,8 @@ const { parseLMStudioBasePath } = require("../../AiProviders/lmStudio"); -const { maximumChunkLength } = require("../../helpers"); +const { + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); class LMStudioEmbedder { constructor() { @@ -58,8 +61,8 @@ class LMStudioEmbedder { // get dropped or go unanswered >:( let results = []; let hasError = false; - for (const chunk of textChunks) { - if (hasError) break; // If an error occurred don't continue and exit early. + for (const [idx, chunk] of textChunks.entries()) { + if (hasError) break; results.push( await this.lmstudio.embeddings .create({ @@ -74,7 +77,7 @@ class LMStudioEmbedder { type: "EMPTY_ARR", message: "The embedding was empty from LMStudio", }; - console.log(`Embedding length: ${embedding.length}`); + reportEmbeddingProgress(idx + 1, textChunks.length); return { data: embedding, error: null }; }) .catch((e) => { diff --git a/server/utils/EmbeddingEngines/localAi/index.js b/server/utils/EmbeddingEngines/localAi/index.js index 4c44959b5..9f6a3131e 100644 --- a/server/utils/EmbeddingEngines/localAi/index.js +++ b/server/utils/EmbeddingEngines/localAi/index.js @@ -1,4 +1,8 @@ -const { toChunks, maximumChunkLength } = require("../../helpers"); +const { + toChunks, + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); class LocalAiEmbedder { constructor() { @@ -50,6 +54,7 @@ class LocalAiEmbedder { async embedChunks(textChunks = []) { const embeddingRequests = []; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( new Promise((resolve) => { @@ -60,9 +65,13 @@ class LocalAiEmbedder { dimensions: this.outputDimensions, }) .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: result?.data, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingEngines/native/index.js b/server/utils/EmbeddingEngines/native/index.js index 21773fcb5..c78e3b21d 100644 --- a/server/utils/EmbeddingEngines/native/index.js +++ b/server/utils/EmbeddingEngines/native/index.js @@ -1,6 +1,6 @@ const path = require("path"); const fs = require("fs"); -const { toChunks } = require("../../helpers"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); const { v4 } = require("uuid"); const { SUPPORTED_NATIVE_EMBEDDING_MODELS } = require("./constants"); @@ -244,6 +244,7 @@ class NativeEmbedder { const tmpFilePath = this.#tempfilePath(); const chunks = toChunks(textChunks, this.maxConcurrentChunks); const chunkLen = chunks.length; + const totalChunks = textChunks.length; for (let [idx, chunk] of chunks.entries()) { if (idx === 0) await this.#writeToTempfile(tmpFilePath, "["); @@ -266,6 +267,11 @@ class NativeEmbedder { this.log(`Embedded Chunk Group ${idx + 1} of ${chunkLen}`); if (chunkLen - 1 !== idx) await this.#writeToTempfile(tmpFilePath, ","); if (chunkLen - 1 === idx) await this.#writeToTempfile(tmpFilePath, "]"); + + reportEmbeddingProgress( + Math.min((idx + 1) * this.maxConcurrentChunks, totalChunks), + totalChunks + ); pipeline = null; output = null; data = null; diff --git a/server/utils/EmbeddingEngines/ollama/index.js b/server/utils/EmbeddingEngines/ollama/index.js index 60ba8e07f..799cb33b3 100644 --- a/server/utils/EmbeddingEngines/ollama/index.js +++ b/server/utils/EmbeddingEngines/ollama/index.js @@ -1,4 +1,7 @@ -const { maximumChunkLength } = require("../../helpers"); +const { + maximumChunkLength, + reportEmbeddingProgress, +} = require("../../helpers"); const { Ollama } = require("ollama"); const { OllamaAILLM } = require("../../AiProviders/ollama"); @@ -111,6 +114,7 @@ class OllamaEmbedder { // but input param returns an array of embeddings (number[][]) for batch processing. // This is why we spread the embeddings array into the data array. data.push(...embeddings); + reportEmbeddingProgress(data.length, textChunks.length); this.log( `Batch ${currentBatch}/${totalBatches}: Embedded ${embeddings.length} chunks. Total: ${data.length}/${textChunks.length}` ); diff --git a/server/utils/EmbeddingEngines/openAi/index.js b/server/utils/EmbeddingEngines/openAi/index.js index dd517ae30..6a809d9ee 100644 --- a/server/utils/EmbeddingEngines/openAi/index.js +++ b/server/utils/EmbeddingEngines/openAi/index.js @@ -1,4 +1,4 @@ -const { toChunks } = require("../../helpers"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); class OpenAiEmbedder { constructor() { @@ -35,6 +35,7 @@ class OpenAiEmbedder { // we concurrently execute each max batch of text chunks possible. // Refer to constructor maxConcurrentChunks for more info. const embeddingRequests = []; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( new Promise((resolve) => { @@ -44,9 +45,13 @@ class OpenAiEmbedder { input: chunk, }) .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: result?.data, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingEngines/openRouter/index.js b/server/utils/EmbeddingEngines/openRouter/index.js index 22dd365b2..f82e6da0f 100644 --- a/server/utils/EmbeddingEngines/openRouter/index.js +++ b/server/utils/EmbeddingEngines/openRouter/index.js @@ -1,4 +1,4 @@ -const { toChunks } = require("../../helpers"); +const { toChunks, reportEmbeddingProgress } = require("../../helpers"); class OpenRouterEmbedder { constructor() { @@ -37,6 +37,7 @@ class OpenRouterEmbedder { async embedChunks(textChunks = []) { this.log(`Embedding ${textChunks.length} document chunks...`); const embeddingRequests = []; + let chunksProcessed = 0; for (const chunk of toChunks(textChunks, this.maxConcurrentChunks)) { embeddingRequests.push( new Promise((resolve) => { @@ -46,9 +47,13 @@ class OpenRouterEmbedder { input: chunk, }) .then((result) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); resolve({ data: result?.data, error: null }); }) .catch((e) => { + chunksProcessed += chunk.length; + reportEmbeddingProgress(chunksProcessed, textChunks.length); e.type = e?.response?.data?.error?.code || e?.response?.status || diff --git a/server/utils/EmbeddingWorkerManager.js b/server/utils/EmbeddingWorkerManager.js new file mode 100644 index 000000000..88eee1a2a --- /dev/null +++ b/server/utils/EmbeddingWorkerManager.js @@ -0,0 +1,202 @@ +const path = require("path"); +const { EventLogs } = require("../models/eventLogs"); + +/** @type {Map} */ +const runningWorkers = new Map(); + +/** @type {Map>} */ +const sseConnections = new Map(); + +/** @type {Map} Buffered events per workspace for SSE replay */ +const eventHistory = new Map(); + +/** + * Write an SSE event payload to all connected clients for a workspace. + * Also called by Document.addDocuments for the non-native embedder path. + */ +function emitProgress(slug, event) { + if (typeof event === "object" && event !== null) { + if (!eventHistory.has(slug)) eventHistory.set(slug, []); + eventHistory.get(slug).push(event); + + if (event.type === "all_complete") + setTimeout(() => eventHistory.delete(slug), 10_000); + } + + const connections = sseConnections.get(slug); + if (!connections || connections.size === 0) return; + const data = `data: ${typeof event === "string" ? event : JSON.stringify(event)}\n\n`; + for (const res of connections) { + try { + res.write(data); + } catch { + connections.delete(res); + } + } +} + +function logEmbeddingEvent(msg) { + EventLogs.logEvent( + "workspace_documents_added", + { + workspaceName: msg.workspaceSlug, + embeddedFiles: msg.embeddedFiles ?? [], + failedFiles: msg.failedFiles ?? [], + embedded: msg.embedded ?? 0, + failed: msg.failed ?? 0, + }, + msg.userId ?? null + ).catch(() => {}); +} + +function addSSEConnection(slug, res) { + if (!sseConnections.has(slug)) sseConnections.set(slug, new Set()); + sseConnections.get(slug).add(res); + + // Only replay buffered events when a worker is actively running. + // If the worker has already exited the history is stale (e.g. contains + // all_complete from a previous run) and replaying it would poison a + // new SSE connection opened for a subsequent embedding job. + if (!runningWorkers.has(slug)) return; + + const history = eventHistory.get(slug); + if (history) { + for (const event of history) { + try { + res.write(`data: ${JSON.stringify(event)}\n\n`); + } catch { + break; + } + } + } +} + +function removeSSEConnection(slug, res) { + const set = sseConnections.get(slug); + if (!set) return; + set.delete(res); + if (set.size === 0) sseConnections.delete(slug); +} + +/** + * Dispatch files to an embedding worker for the native embedder. + * If a worker is already running for this workspace, appends files to it. + * Otherwise spawns a new worker via BackgroundService. + * + * @param {string} slug - Workspace slug + * @param {string[]} files - Document paths to embed + * @param {number} workspaceId - Workspace DB id + * @param {number|null} userId + */ +async function embedFiles(slug, files, workspaceId, userId) { + if (runningWorkers.has(slug)) { + const { worker } = runningWorkers.get(slug); + try { + worker.send({ type: "add_files", files }); + return; + } catch { + runningWorkers.delete(slug); + } + } + + // Clear stale event history from any previous run so new SSE + // connections don't replay old events (including all_complete). + eventHistory.delete(slug); + + const { BackgroundService } = require("./BackgroundWorkers"); + const bg = new BackgroundService(); + const scriptPath = path.resolve(bg.jobsRoot, "embedding-worker.js"); + const { worker, jobId } = await bg.spawnWorker(scriptPath); + + runningWorkers.set(slug, { worker, jobId }); + let workerCompleted = false; + worker.on("message", (msg) => { + if (!msg || !msg.type) return; + if (msg.type === "all_complete") { + workerCompleted = true; + logEmbeddingEvent(msg); + } + emitProgress(slug, msg); + }); + + worker.on("exit", (code) => { + if (runningWorkers.get(slug)?.worker === worker) { + runningWorkers.delete(slug); + } + bg.removeJob(jobId).catch(() => {}); + + if (!workerCompleted) { + emitProgress(slug, { + type: "all_complete", + workspaceSlug: slug, + error: `Worker exited unexpectedly (code ${code ?? "unknown"})`, + embedded: 0, + failed: 0, + }); + } + }); + + worker.on("error", (err) => { + console.error( + `[EmbeddingWorkerManager] Worker error for ${slug}:`, + err.message + ); + if (runningWorkers.get(slug)?.worker === worker) { + runningWorkers.delete(slug); + } + }); + + worker.send({ + type: "embed", + files, + workspaceSlug: slug, + workspaceId, + userId, + }); +} + +/** + * Remove a queued (not yet processing) file from the embedding worker. + * @param {string} slug - Workspace slug + * @param {string} filename - Document path to dequeue + * @returns {boolean} true if the message was sent to the worker + */ +function removeQueuedFile(slug, filename) { + const entry = runningWorkers.get(slug); + if (!entry) return false; + try { + entry.worker.send({ type: "remove_file", filename }); + } catch { + return false; + } + + // Scrub the file from the event history so replayed SSE state is consistent. + const history = eventHistory.get(slug); + if (history) { + const cleaned = history.filter( + (e) => !(e.filename === filename && e.type !== "file_removed") + ); + for (const e of cleaned) { + if (e.type === "batch_starting" && e.filenames) { + e.filenames = e.filenames.filter((f) => f !== filename); + e.totalDocs = e.filenames.length; + } + } + eventHistory.set(slug, cleaned); + } + return true; +} + +function isNativeEmbedder() { + const engine = process.env.EMBEDDING_ENGINE; + return !engine || engine === "native"; +} + +module.exports = { + emitProgress, + addSSEConnection, + removeSSEConnection, + embedFiles, + removeQueuedFile, + isNativeEmbedder, +}; diff --git a/server/utils/agentFlows/executors/llm-instruction.js b/server/utils/agentFlows/executors/llm-instruction.js index d7e4e57fa..d594c547e 100644 --- a/server/utils/agentFlows/executors/llm-instruction.js +++ b/server/utils/agentFlows/executors/llm-instruction.js @@ -23,13 +23,17 @@ async function executeLLMInstruction(config, context) { if (typeof input === "object") input = JSON.stringify(input); if (typeof input !== "string") input = String(input); + let completion; const provider = aibitat.getProviderForConfig(aibitat.defaultProvider); - const completion = await provider.complete([ - { - role: "user", - content: input, - }, - ]); + if (provider.supportsAgentStreaming) { + completion = await provider.stream( + [{ role: "user", content: input }], + [], + null + ); + } else { + completion = await provider.complete([{ role: "user", content: input }]); + } introspect(`Successfully received LLM response`); if (resultVariable) config.resultVariable = resultVariable; diff --git a/server/utils/agentFlows/index.js b/server/utils/agentFlows/index.js index 934a5bb15..2bd5ec725 100644 --- a/server/utils/agentFlows/index.js +++ b/server/utils/agentFlows/index.js @@ -2,7 +2,7 @@ const fs = require("fs"); const path = require("path"); const { v4: uuidv4 } = require("uuid"); const { FlowExecutor, FLOW_TYPES } = require("./executor"); -const { normalizePath } = require("../files"); +const { normalizePath, isWithin } = require("../files"); const { safeJsonParse } = require("../http"); /** @@ -71,7 +71,12 @@ class AgentFlows { const flowJsonPath = normalizePath( path.join(AgentFlows.flowsDir, `${uuid}.json`) ); - if (!uuid || !fs.existsSync(flowJsonPath)) return null; + if ( + !uuid || + !fs.existsSync(flowJsonPath) || + !isWithin(AgentFlows.flowsDir, flowJsonPath) + ) + return null; const flow = safeJsonParse(fs.readFileSync(flowJsonPath, "utf8"), null); if (!flow) return null; @@ -100,6 +105,7 @@ class AgentFlows { if (!uuid) uuid = uuidv4(); const normalizedUuid = normalizePath(`${uuid}.json`); const filePath = path.join(AgentFlows.flowsDir, normalizedUuid); + if (!isWithin(AgentFlows.flowsDir, filePath)) return null; // Prevent saving flows with unsupported blocks or importing // flows with unsupported blocks (eg: file writing or code execution on Desktop importing to Docker) @@ -151,7 +157,8 @@ class AgentFlows { const filePath = normalizePath( path.join(AgentFlows.flowsDir, `${uuid}.json`) ); - if (!fs.existsSync(filePath)) throw new Error(`Flow ${uuid} not found`); + if (!fs.existsSync(filePath) || !isWithin(AgentFlows.flowsDir, filePath)) + throw new Error(`Flow ${uuid} not found`); fs.rmSync(filePath); return { success: true }; } catch (error) { diff --git a/server/utils/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js index 37a285b39..65783cf10 100644 --- a/server/utils/agents/aibitat/index.js +++ b/server/utils/agents/aibitat/index.js @@ -33,7 +33,7 @@ class AIbitat { defaultInterrupt; maxRounds; _chats; - + _trackedChatId = null; agents = new Map(); channels = new Map(); functions = new Map(); @@ -114,6 +114,44 @@ class AIbitat { return this; } + /** + * Register a new chat ID for tracking for a given conversation exchange + * @param {number} chatId - The ID of the chat to register. + */ + registerChatId(chatId = null) { + if (!chatId) return; + this._trackedChatId = Number(chatId); + } + + /** + * Get the tracked chat ID for a given conversation exchange + * @returns {number|null} The ID of the chat to register. + */ + get trackedChatId() { + return this._trackedChatId ?? null; + } + + /** + * Clear the tracked chat ID for a given conversation exchange + */ + clearTrackedChatId() { + this._trackedChatId = null; + } + + /** + * Emit the tracked chat ID to the frontend via the websocket + * plugin (assumed to be attached). + * @param {string} [uuid] - The message UUID to associate with this chatId + */ + emitChatId(uuid = null) { + if (!this.trackedChatId || !uuid) return null; + this.socket?.send?.("reportStreamEvent", { + type: "chatId", + uuid, + chatId: this.trackedChatId, + }); + } + /** * Add citation(s) to be reported when the response is finalized. * Citations are buffered and flushed with the correct message UUID. @@ -862,6 +900,16 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection const { name, arguments: args } = completionStream.functionCall; const fn = this.functions.get(name); + const reachedToolLimit = depth >= this.maxToolCalls; + + if (reachedToolLimit) { + this.handlerProps?.log?.( + `[warning]: Maximum tool call limit (${this.maxToolCalls}) reached. Executing final tool call then generating response.` + ); + this?.introspect?.( + `Maximum tool call limit (${this.maxToolCalls}) reached. After this tool I will generate a final response.` + ); + } if (!fn) { return await this.handleAsyncExecution( @@ -875,7 +923,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection originalFunctionCall: completionStream.functionCall, }, ], - functions, + reachedToolLimit ? [] : functions, byAgent, depth + 1 ); @@ -923,6 +971,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection metrics: provider.getUsage(), }); this?.flushCitations?.(directOutputUUID); + this?.emitChatId?.(directOutputUUID); return result; } @@ -951,7 +1000,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection return await this.handleAsyncExecution( provider, newMessages, - functions, + reachedToolLimit ? [] : functions, byAgent, depth + 1 ); @@ -964,6 +1013,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection metrics: provider.getUsage(), }); this?.flushCitations?.(responseUuid); + this?.emitChatId?.(responseUuid); return completionStream?.textResponse; } @@ -1025,6 +1075,16 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection const { name, arguments: args } = completion.functionCall; const fn = this.functions.get(name); + const reachedToolLimit = depth >= this.maxToolCalls; + + if (reachedToolLimit) { + this.handlerProps?.log?.( + `[warning]: Maximum tool call limit (${this.maxToolCalls}) reached. Executing final tool call then generating response.` + ); + this?.introspect?.( + `Maximum tool call limit (${this.maxToolCalls}) reached. After this tool I will generate a final response.` + ); + } if (!fn) { return await this.handleExecution( @@ -1038,7 +1098,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection originalFunctionCall: completion.functionCall, }, ], - functions, + reachedToolLimit ? [] : functions, byAgent, depth + 1, msgUUID @@ -1103,7 +1163,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection return await this.handleExecution( provider, newMessages, - functions, + reachedToolLimit ? [] : functions, byAgent, depth + 1, msgUUID @@ -1116,6 +1176,7 @@ https://docs.anythingllm.com/agent/intelligent-tool-selection metrics: provider.getUsage(), }); this?.flushCitations?.(msgUUID); + this?.emitChatId?.(msgUUID); return completion?.textResponse; } diff --git a/server/utils/agents/aibitat/plugins/chat-history.js b/server/utils/agents/aibitat/plugins/chat-history.js index 99b0ca641..edc6b7767 100644 --- a/server/utils/agents/aibitat/plugins/chat-history.js +++ b/server/utils/agents/aibitat/plugins/chat-history.js @@ -12,6 +12,46 @@ const chatHistory = { return { name: this.name, setup: function (aibitat) { + // pre-register a workspace chat ID to secure it in the DB + aibitat.onMessage(async (message) => { + if (message.from !== "USER") return; + + /** + * If we don't have a tracked chat ID, we need to create a new one so we can upsert the response later. + * Normally, if this was a totally fresh chat from the user, we can assume that the message from the socket is + * the message we want to store for the prompt. However, if this is a regeneration of a previous message and that message + * called tools the history could include intermediate messages so need to search backwards to find the most recent user message + * as that is actually the prompt. + */ + if (!aibitat.trackedChatId) { + let userMessage = message.content; + if (userMessage.startsWith("@agent:")) { + const lastUserMsgIndex = aibitat._chats.findLastIndex( + (c) => c.from === "USER" && !c.content.startsWith("@agent:") + ); + + // When regenerating a message, we need to use the last user message as the prompt. + // Also prune the chats array to only include the messages before target prompt to re-run + // or else tool call results from the previous run will be included in the history and the model will not re-call tools + // that previously worked for the to-be-regenerated prompt. + if (lastUserMsgIndex !== -1) { + userMessage = aibitat._chats[lastUserMsgIndex].content; + aibitat._chats = aibitat._chats.slice(0, lastUserMsgIndex + 1); + } + } + + const { chat } = await WorkspaceChats.new({ + workspaceId: Number(aibitat.handlerProps.invocation.workspace_id), + user: { id: aibitat.handlerProps.invocation.user_id || null }, + threadId: aibitat.handlerProps.invocation.thread_id || null, + include: false, + prompt: userMessage, + response: {}, + }); + if (chat) aibitat.registerChatId(chat.id); + } + }); + aibitat.onMessage(async () => { try { const lastResponses = aibitat.chats.slice(-2); @@ -54,7 +94,7 @@ const chatHistory = { const metrics = aibitat.provider?.getUsage?.() ?? {}; const citations = aibitat._pendingCitations ?? []; const outputs = aibitat._pendingOutputs ?? []; - await WorkspaceChats.new({ + await WorkspaceChats.upsert(aibitat.trackedChatId, { workspaceId: Number(invocation.workspace_id), prompt, response: { @@ -67,9 +107,9 @@ const chatHistory = { }, user: { id: invocation?.user_id || null }, threadId: invocation?.thread_id || null, + include: true, }); - aibitat.clearCitations?.(); - aibitat._pendingOutputs = []; + this._cleanup(aibitat); }, _storeSpecial: async function ( aibitat, @@ -80,7 +120,7 @@ const chatHistory = { const citations = aibitat._pendingCitations ?? []; const outputs = aibitat._pendingOutputs ?? []; const existingSources = options?.sources ?? []; - await WorkspaceChats.new({ + await WorkspaceChats.upsert(aibitat.trackedChatId, { workspaceId: Number(invocation.workspace_id), prompt, response: { @@ -97,10 +137,18 @@ const chatHistory = { }, user: { id: invocation?.user_id || null }, threadId: invocation?.thread_id || null, + include: true, }); aibitat.clearCitations?.(); aibitat._pendingOutputs = []; options?.postSave(); + this._cleanup(aibitat); + }, + + _cleanup: function (aibitat) { + aibitat.clearCitations?.(); + aibitat._pendingOutputs = []; + aibitat.clearTrackedChatId(); }, }; }, diff --git a/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js b/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js index 4ead34e36..664f43263 100644 --- a/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js +++ b/server/utils/agents/aibitat/plugins/create-files/pdf/create-pdf-file.js @@ -86,7 +86,7 @@ module.exports.CreatePdfFile = { `${this.caller}: Creating PDF document "${filename}"` ); - const { markdownToPdf } = await import("@mdpdf/mdpdf"); + const { markdownToPdf } = await import("@mintplex-labs/mdpdf"); const { PDFDocument, rgb, StandardFonts } = await import( "pdf-lib" ); diff --git a/server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js b/server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js new file mode 100644 index 000000000..c63b62621 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/account/gmail-get-mailbox-stats.js @@ -0,0 +1,75 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailGetMailboxStats = { + name: "gmail-get-mailbox-stats", + plugin: function () { + return { + name: "gmail-get-mailbox-stats", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Used for general account information. Reports Gmail mailbox statistics including unread counts for inbox, " + + "priority inbox, starred messages, and spam folder.", + examples: [ + { + prompt: "How much of my mailbox quota is remaining?", + call: JSON.stringify({}), + }, + { + prompt: "Show me my mailbox statistics", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + try { + this.super.handlerProps.log( + `Using the gmail-get-mailbox-stats tool.` + ); + + this.super.introspect( + `${this.caller}: Getting Gmail mailbox statistics` + ); + + const result = await gmailLib.getMailboxStats(); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get mailbox stats - ${result.error}` + ); + return `Error getting mailbox statistics: ${result.error}`; + } + + const stats = result.data; + this.super.introspect( + `${this.caller}: Successfully retrieved mailbox statistics` + ); + + return ( + `Gmail Mailbox Statistics:\n\n` + + `Inbox Unread: ${stats.inboxUnreadCount}\n` + + `Priority Inbox Unread: ${stats.priorityInboxUnreadCount}\n` + + `Starred Unread: ${stats.starredUnreadCount}\n` + + `Spam Unread: ${stats.spamUnreadCount}\n\n` + + `Use gmail-search to find and read specific emails.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-get-mailbox-stats error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting mailbox statistics: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js new file mode 100644 index 000000000..1809d5156 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft-reply.js @@ -0,0 +1,220 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailCreateDraftReply = { + name: "gmail-create-draft-reply", + plugin: function () { + return { + name: "gmail-create-draft-reply", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a draft reply to an existing email thread in Gmail. " + + "The draft will be saved but not sent. You can choose to reply to all recipients or just the sender. " + + "Supports file attachments.", + examples: [ + { + prompt: "Create a draft reply to thread 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thank you for your email. I will review this and get back to you shortly.", + replyAll: false, + }), + }, + { + prompt: "Draft a reply-all response to the thread", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thanks everyone for your input. Here are my thoughts...", + replyAll: true, + }), + }, + { + prompt: "Create a draft reply with an attachment", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Please find the requested document attached.", + attachments: ["/Users/me/Documents/document.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to reply to.", + }, + body: { + type: "string", + description: "Plain text reply body content.", + }, + replyAll: { + type: "boolean", + description: + "Whether to reply to all recipients. Defaults to false (reply to sender only).", + default: false, + }, + cc: { + type: "string", + description: + "Additional CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the reply body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft reply.", + }, + }, + required: ["threadId", "body"], + additionalProperties: false, + }, + handler: async function ({ + threadId, + body, + replyAll = false, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log( + `Using the gmail-create-draft-reply tool.` + ); + + if (!threadId || !body) { + return "Error: 'threadId' and 'body' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + threadId, + replyAll, + attachmentCount: preparedAttachments.length, + }, + description: `Create Gmail draft reply to thread "${threadId}"${replyAll ? " (reply all)" : ""}${attachmentNote}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating draft reply to thread ${threadId}${replyAll ? " (reply all)" : ""}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.createDraftReply( + threadId, + body, + replyAll, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create draft reply - ${result.error}` + ); + return `Error creating Gmail draft reply: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully created draft reply (ID: ${draft.draftId})` + ); + + return ( + `Successfully created Gmail draft reply:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + `Subject: ${draft.subject}\n` + + `Reply Type: ${replyAll ? "Reply All" : "Reply"}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft reply has been saved and can be edited or sent later.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-create-draft-reply error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating Gmail draft reply: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js new file mode 100644 index 000000000..9b2b0ee21 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-create-draft.js @@ -0,0 +1,217 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailCreateDraft = { + name: "gmail-create-draft", + plugin: function () { + return { + name: "gmail-create-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a new draft email in Gmail. The draft will be saved but not sent. " + + "You can optionally include CC, BCC recipients, HTML body content, and file attachments.", + examples: [ + { + prompt: + "Create a draft email to john@example.com about the meeting", + call: JSON.stringify({ + to: "john@example.com", + subject: "Meeting Tomorrow", + body: "Hi John,\n\nJust wanted to confirm our meeting tomorrow at 2pm.\n\nBest regards", + }), + }, + { + prompt: "Draft an email with CC recipients", + call: JSON.stringify({ + to: "john@example.com", + subject: "Project Update", + body: "Please see the attached project update.", + cc: "manager@example.com", + }), + }, + { + prompt: "Create a draft with an attachment", + call: JSON.stringify({ + to: "john@example.com", + subject: "Report", + body: "Please find the report attached.", + attachments: ["/Users/me/Documents/report.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + to: { + type: "string", + description: + "Recipient email address(es). Multiple addresses can be comma-separated.", + }, + subject: { + type: "string", + description: "Email subject line.", + }, + body: { + type: "string", + description: "Plain text email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the email body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft.", + }, + }, + required: ["to", "subject", "body"], + additionalProperties: false, + }, + handler: async function ({ + to, + subject, + body, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the gmail-create-draft tool.`); + + if (!to || !subject || !body) { + return "Error: 'to', 'subject', and 'body' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description: `Create Gmail draft to "${to}" with subject "${subject}"${attachmentNote}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating Gmail draft to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.createDraft( + to, + subject, + body, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create draft - ${result.error}` + ); + return `Error creating Gmail draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully created draft (ID: ${draft.draftId})` + ); + + return ( + `Successfully created Gmail draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + `Subject: ${draft.subject}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft has been saved and can be edited or sent later.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-create-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js new file mode 100644 index 000000000..ce569e8e6 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-delete-draft.js @@ -0,0 +1,87 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailDeleteDraft = { + name: "gmail-delete-draft", + plugin: function () { + return { + name: "gmail-delete-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Delete a draft email from Gmail. " + + "This action is permanent and cannot be undone.", + examples: [ + { + prompt: "Delete the draft with ID r123456", + call: JSON.stringify({ + draftId: "r123456", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to delete.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the gmail-delete-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Delete Gmail draft "${draftId}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Deleting Gmail draft ${draftId}` + ); + + const result = await gmailLib.deleteDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to delete draft - ${result.error}` + ); + return `Error deleting Gmail draft: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully deleted draft ${draftId}` + ); + + return `Successfully deleted Gmail draft (ID: ${draftId}).`; + } catch (e) { + this.super.handlerProps.log( + `gmail-delete-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error deleting Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js new file mode 100644 index 000000000..66c3829cd --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-get-draft.js @@ -0,0 +1,84 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailGetDraft = { + name: "gmail-get-draft", + plugin: function () { + return { + name: "gmail-get-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Retrieve a specific draft email by its ID. " + + "Returns the draft details including recipient, subject, and body content.", + examples: [ + { + prompt: "Get the draft with ID r123456", + call: JSON.stringify({ + draftId: "r123456", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to retrieve.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the gmail-get-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + this.super.introspect( + `${this.caller}: Retrieving Gmail draft ${draftId}` + ); + + const result = await gmailLib.getDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get draft - ${result.error}` + ); + return `Error retrieving Gmail draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully retrieved draft (ID: ${draft.draftId})` + ); + + return ( + `Gmail Draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + (draft.cc ? `CC: ${draft.cc}\n` : "") + + (draft.bcc ? `BCC: ${draft.bcc}\n` : "") + + `Subject: ${draft.subject}\n` + + `Date: ${new Date(draft.date).toLocaleString()}\n` + + `\n--- Body ---\n${draft.body}` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-get-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error retrieving Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js new file mode 100644 index 000000000..e41241d7d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-list-drafts.js @@ -0,0 +1,96 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailListDrafts = { + name: "gmail-list-drafts", + plugin: function () { + return { + name: "gmail-list-drafts", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all draft emails in Gmail. " + + "Returns a summary of each draft including ID, recipient, subject, and date.", + examples: [ + { + prompt: "List my email drafts", + call: JSON.stringify({ + limit: 25, + }), + }, + { + prompt: "Show me the first 10 drafts", + call: JSON.stringify({ + limit: 10, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of drafts to return (1-100). Defaults to 25.", + default: 25, + }, + }, + additionalProperties: false, + }, + handler: async function ({ limit = 25 }) { + try { + this.super.handlerProps.log(`Using the gmail-list-drafts tool.`); + + this.super.introspect( + `${this.caller}: Listing Gmail drafts (limit: ${limit})` + ); + + const result = await gmailLib.listDrafts(limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to list drafts - ${result.error}` + ); + return `Error listing Gmail drafts: ${result.error}`; + } + + const { totalDrafts, returned, drafts } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalDrafts} total drafts, returning ${returned}` + ); + + if (totalDrafts === 0) { + return "No drafts found in Gmail."; + } + + const summary = drafts + .map( + (d, i) => + `${i + 1}. Draft ID: ${d.draftId}\n` + + ` To: ${d.to || "(no recipient)"}\n` + + ` Subject: ${d.subject || "(no subject)"}\n` + + ` Date: ${new Date(d.date).toLocaleString()}` + ) + .join("\n\n"); + + return ( + `Gmail Drafts (${returned} of ${totalDrafts} total):\n\n${summary}\n\n` + + `Use the draft ID with gmail-get-draft to view full content, ` + + `gmail-update-draft to edit, gmail-delete-draft to remove, ` + + `or gmail-send-draft to send.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-list-drafts error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error listing Gmail drafts: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js new file mode 100644 index 000000000..e06bc6ba2 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-send-draft.js @@ -0,0 +1,94 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailSendDraft = { + name: "gmail-send-draft", + plugin: function () { + return { + name: "gmail-send-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an existing draft email from Gmail. " + + "This will send the draft immediately and remove it from drafts. " + + "This action cannot be undone.", + examples: [ + { + prompt: "Send the draft with ID r123456", + call: JSON.stringify({ + draftId: "r123456", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to send.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the gmail-send-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Send Gmail draft "${draftId}" - This will send the email immediately`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Sending Gmail draft ${draftId}` + ); + + const result = await gmailLib.sendDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send draft - ${result.error}` + ); + return `Error sending Gmail draft: ${result.error}`; + } + + const { messageId, threadId } = result.data; + this.super.introspect( + `${this.caller}: Successfully sent draft as message ${messageId}` + ); + + return ( + `Successfully sent Gmail draft:\n` + + `Message ID: ${messageId}\n` + + `Thread ID: ${threadId}\n\n` + + `The email has been sent and removed from drafts.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-send-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error sending Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js new file mode 100644 index 000000000..8f7ae0066 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/drafts/gmail-update-draft.js @@ -0,0 +1,217 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailUpdateDraft = { + name: "gmail-update-draft", + plugin: function () { + return { + name: "gmail-update-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Update an existing draft email in Gmail. " + + "You must provide the draft ID and the new content for the draft. " + + "Supports file attachments.", + examples: [ + { + prompt: "Update draft r123456 with new content", + call: JSON.stringify({ + draftId: "r123456", + to: "john@example.com", + subject: "Updated: Meeting Tomorrow", + body: "Hi John,\n\nThe meeting has been rescheduled to 3pm.\n\nBest regards", + }), + }, + { + prompt: "Update draft with an attachment", + call: JSON.stringify({ + draftId: "r123456", + to: "john@example.com", + subject: "Report", + body: "Please find the updated report attached.", + attachments: ["/Users/me/Documents/report.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The Gmail draft ID to update.", + }, + to: { + type: "string", + description: + "Recipient email address(es). Multiple addresses can be comma-separated.", + }, + subject: { + type: "string", + description: "Email subject line.", + }, + body: { + type: "string", + description: "Plain text email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the email body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft.", + }, + }, + required: ["draftId", "to", "subject", "body"], + additionalProperties: false, + }, + handler: async function ({ + draftId, + to, + subject, + body, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the gmail-update-draft tool.`); + + if (!draftId || !to || !subject) { + return "Error: 'draftId', 'to', and 'subject' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + draftId, + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description: `Update Gmail draft "${draftId}"${attachmentNote}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Updating Gmail draft ${draftId}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.updateDraft( + draftId, + to, + subject, + body, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to update draft - ${result.error}` + ); + return `Error updating Gmail draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully updated draft (ID: ${draft.draftId})` + ); + + return ( + `Successfully updated Gmail draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `Message ID: ${draft.messageId}\n` + + `To: ${draft.to}\n` + + `Subject: ${draft.subject}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft has been updated.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-update-draft error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error updating Gmail draft: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/index.js b/server/utils/agents/aibitat/plugins/gmail/index.js new file mode 100644 index 000000000..506a9ed7f --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/index.js @@ -0,0 +1,73 @@ +// Get Inbox & Search +const { GmailGetInbox } = require("./search/gmail-get-inbox.js"); +const { GmailSearch } = require("./search/gmail-search.js"); +const { GmailReadThread } = require("./search/gmail-read-thread.js"); + +// Drafts +const { GmailCreateDraft } = require("./drafts/gmail-create-draft.js"); +const { + GmailCreateDraftReply, +} = require("./drafts/gmail-create-draft-reply.js"); +const { GmailUpdateDraft } = require("./drafts/gmail-update-draft.js"); +const { GmailGetDraft } = require("./drafts/gmail-get-draft.js"); +const { GmailListDrafts } = require("./drafts/gmail-list-drafts.js"); +const { GmailDeleteDraft } = require("./drafts/gmail-delete-draft.js"); +const { GmailSendDraft } = require("./drafts/gmail-send-draft.js"); + +// Send & Reply +const { GmailSendEmail } = require("./send/gmail-send-email.js"); +const { GmailReplyToThread } = require("./send/gmail-reply-to-thread.js"); + +// Thread Management +const { GmailMarkRead } = require("./threads/gmail-mark-read.js"); +const { GmailMarkUnread } = require("./threads/gmail-mark-unread.js"); +const { GmailMoveToTrash } = require("./threads/gmail-move-to-trash.js"); +const { GmailMoveToArchive } = require("./threads/gmail-move-to-archive.js"); +const { GmailMoveToInbox } = require("./threads/gmail-move-to-inbox.js"); + +// Account +const { + GmailGetMailboxStats, +} = require("./account/gmail-get-mailbox-stats.js"); + +const gmailAgent = { + name: "gmail-agent", + startupConfig: { + params: {}, + }, + plugin: [ + // Alias for easy access to the inbox + GmailGetInbox, + + // Search & Read (read-only) + GmailSearch, + GmailReadThread, + + // Drafts (modifying) + GmailCreateDraft, + GmailCreateDraftReply, + GmailUpdateDraft, + GmailGetDraft, + GmailListDrafts, + GmailDeleteDraft, + GmailSendDraft, + + // Send & Reply (modifying) + GmailSendEmail, + GmailReplyToThread, + + // Thread Management (modifying) + GmailMarkRead, + GmailMarkUnread, + GmailMoveToTrash, + GmailMoveToArchive, + GmailMoveToInbox, + + // Account (read-only) + GmailGetMailboxStats, + ], +}; + +module.exports = { + gmailAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/lib.js b/server/utils/agents/aibitat/plugins/gmail/lib.js new file mode 100644 index 000000000..c751444b4 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/lib.js @@ -0,0 +1,573 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const mime = require("mime"); +const { SystemSettings } = require("../../../../../models/systemSettings"); +const { CollectorApi } = require("../../../../collectorApi"); +const { humanFileSize } = require("../../../../helpers"); +const { safeJsonParse } = require("../../../../http"); + +const MAX_TOTAL_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20MB limit for all attachments combined + +/** + * Validates and prepares a file attachment for email. + * Note: Does not check total size limit - caller should track cumulative size. + * @param {string} filePath - Absolute path to the file + * @returns {{success: boolean, attachment?: object, error?: string, fileInfo?: object}} + */ +function prepareAttachment(filePath) { + if (process.env.ANYTHING_LLM_RUNTIME === "docker") { + return { + success: false, + error: "File attachments are not supported in Docker environments.", + }; + } + + if (!path.isAbsolute(filePath)) { + return { success: false, error: `Path must be absolute: ${filePath}` }; + } + + if (!fs.existsSync(filePath)) { + return { success: false, error: `File does not exist: ${filePath}` }; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return { success: false, error: `Path is not a file: ${filePath}` }; + } + + if (stats.size === 0) { + return { success: false, error: `File is empty: ${filePath}` }; + } + + try { + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString("base64"); + const fileName = path.basename(filePath); + const contentType = mime.getType(filePath) || "application/octet-stream"; + + return { + success: true, + attachment: { + name: fileName, + contentType, + data: base64Data, + }, + fileInfo: { + path: filePath, + name: fileName, + size: stats.size, + sizeFormatted: humanFileSize(stats.size, true), + contentType, + }, + }; + } catch (e) { + return { success: false, error: `Failed to read file: ${e.message}` }; + } +} + +/** + * Parse an attachment using the CollectorApi for secure content extraction. + * Writes the base64 data to a temp file, parses it, then cleans up. + * @param {Object} attachment - The attachment object with name, contentType, size, data (base64) + * @returns {Promise<{success: boolean, content: string|null, error: string|null}>} + */ +async function parseAttachment(attachment) { + const tempDir = os.tmpdir(); + const safeFilename = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const tempFilePath = path.join( + tempDir, + `gmail-attachment-${Date.now()}-${safeFilename}` + ); + + try { + const buffer = Buffer.from(attachment.data, "base64"); + fs.writeFileSync(tempFilePath, buffer); + + const collector = new CollectorApi(); + const result = await collector.parseDocument(safeFilename, { + absolutePath: tempFilePath, + }); + + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + if (!result.success) { + return { + success: false, + content: null, + error: result.reason || "Failed to parse attachment", + }; + } + + const textContent = result.documents + ?.map((doc) => doc.pageContent || doc.content || "") + .filter(Boolean) + .join("\n\n"); + + return { + success: true, + content: textContent || "(No text content extracted)", + error: null, + }; + } catch (e) { + if (fs.existsSync(tempFilePath)) { + try { + fs.unlinkSync(tempFilePath); + } catch {} + } + return { success: false, content: null, error: e.message }; + } +} + +/** + * Collect attachments from messages and optionally parse them with user approval. + * Specific files may not show (images) and are pre-stripped by the app script. + * If two attachments have the same name, only the first one will be kept (handling fwd emails) + * @param {Object} context - The handler context (this) from the aibitat function + * @param {Array} messages - Array of message objects (single message should be wrapped in array) + * @returns {Promise<{allAttachments: Array, parsedContent: string}>} + */ +async function handleAttachments(context, messages) { + const allAttachments = []; + const uniqueAttachments = new Set(); + messages.forEach((msg, msgIndex) => { + if (msg.attachments?.length > 0) { + msg.attachments.forEach((att) => { + if (uniqueAttachments.has(att.name)) return; + uniqueAttachments.add(att.name); + allAttachments.push({ + ...att, + messageIndex: msgIndex + 1, + messageId: msg.id, + }); + }); + } + }); + + let parsedContent = ""; + const citations = []; + if (allAttachments.length > 0 && context.super.requestToolApproval) { + const attachmentNames = allAttachments.map((a) => a.name).join(", "); + + const approval = await context.super.requestToolApproval({ + skillName: context.name, + payload: { attachments: attachmentNames }, + description: `Parse attachments (${attachmentNames}) to extract text content?`, + }); + + if (approval.approved) { + context.super.introspect( + `${context.caller}: Parsing ${allAttachments.length} attachment(s)...` + ); + + const parsedResults = []; + for (const attachment of allAttachments) { + if (!attachment.data) continue; + context.super.introspect( + `${context.caller}: Parsing "${attachment.name}"...` + ); + const parseResult = await parseAttachment(attachment); + if (!parseResult.success) continue; + + citations.push({ + id: `gmail-attachment-${attachment.messageId}-${attachment.name}`, + title: attachment.name, + text: parseResult.content, + chunkSource: "gmail-attachment://" + attachment.name, + score: null, + }); + parsedResults.push({ + name: attachment.name, + messageIndex: attachment.messageIndex, + ...parseResult, + }); + } + + parsedContent = + "\n\n--- Parsed Attachment Content ---\n" + + parsedResults + .map((r) => `\n[Message ${r.messageIndex}: ${r.name}]\n${r.content}`) + .join("\n"); + + context.super.introspect( + `${context.caller}: Finished parsing attachments` + ); + } else { + context.super.introspect( + `${context.caller}: User declined to parse attachments` + ); + } + } + + citations.forEach((c) => context.super.addCitation?.(c)); + return { allAttachments, parsedContent }; +} + +/** + * Gmail Bridge Library + * Handles communication with the AnythingLLM Gmail Google Apps Script deployment. + */ +class GmailBridge { + #deploymentId = null; + #apiKey = null; + #isInitialized = false; + + #log(text, ...args) { + console.log(`\x1b[36m[GmailBridge]\x1b[0m ${text}`, ...args); + } + + /** + * Resets the bridge state, forcing re-initialization on next use. + * Call this when configuration changes (e.g., deployment ID updated). + */ + reset() { + this.#deploymentId = null; + this.#apiKey = null; + this.#isInitialized = false; + } + + /** + * Gets the current Gmail agent configuration from system settings. + * @returns {Promise<{deploymentId?: string, apiKey?: string}>} + */ + static async getConfig() { + const configJson = await SystemSettings.getValueOrFallback( + { label: "gmail_agent_config" }, + "{}" + ); + return safeJsonParse(configJson, {}); + } + + /** + * Updates the Gmail agent configuration in system settings. + * @param {Object} updates - Fields to update + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async updateConfig(updates) { + try { + await SystemSettings.updateSettings({ + gmail_agent_config: JSON.stringify(updates), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Initializes the Gmail bridge by fetching configuration from system settings. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async initialize() { + if (this.#isInitialized) return { success: true }; + + try { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) { + return { + success: false, + error: + "Gmail integration is not available in multi-user mode for security reasons.", + }; + } + + const config = await GmailBridge.getConfig(); + if (!config.deploymentId || !config.apiKey) { + return { + success: false, + error: + "Gmail integration is not configured. Please set the Deployment ID and API Key in the agent settings.", + }; + } + + this.#deploymentId = config.deploymentId; + this.#apiKey = config.apiKey; + this.#isInitialized = true; + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Checks if the Gmail bridge is properly configured and available. + * @returns {Promise} + */ + async isAvailable() { + const result = await this.initialize(); + return result.success; + } + + /** + * Checks if Gmail tools are available (not in multi-user mode and has configuration). + * @returns {Promise} + */ + static async isToolAvailable() { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) return false; + + const config = await GmailBridge.getConfig(); + return !!(config.deploymentId && config.apiKey); + } + + get maskedDeploymentId() { + if (!this.#deploymentId) return "(not configured)"; + return ( + this.#deploymentId.substring(0, 5) + + "..." + + this.#deploymentId.substring(this.#deploymentId.length - 5) + ); + } + + /** + * Gets the base URL for the Gmail Google Apps Script deployment. + * @returns {string} + */ + #getBaseUrl() { + this.#log(`Getting base URL for deployment ID ${this.maskedDeploymentId}`); + return `https://script.google.com/macros/s/${this.#deploymentId}/exec`; + } + + /** + * Makes a request to the Gmail Google Apps Script API. + * @param {string} action - The action to perform + * @param {object} params - Additional parameters for the action + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async request(action, params = {}) { + const initResult = await this.initialize(); + if (!initResult.success) { + return { success: false, error: initResult.error }; + } + + try { + const response = await fetch(this.#getBaseUrl(), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-AnythingLLM-UA": "AnythingLLM-Gmail-Agent/1.0", + }, + body: JSON.stringify({ + key: this.#apiKey, + action, + ...params, + }), + }); + + if (!response.ok) { + return { + success: false, + error: `Gmail API request failed with status ${response.status}`, + }; + } + + const result = await response.json(); + + if (result.status === "error") { + return { success: false, error: result.error }; + } + + return { success: true, data: result.data, quota: result.quota }; + } catch (error) { + return { + success: false, + error: `Gmail API request failed: ${error.message}`, + }; + } + } + + /** + * Search emails using Gmail query syntax. + * @param {string} query - Gmail search query + * @param {number} limit - Maximum results to return + * @param {number} start - Starting offset + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async search(query = "is:inbox", limit = 10, start = 0) { + return this.request("search", { query, limit, start }); + } + + /** + * Read a full thread by ID. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async readThread(threadId) { + return this.request("read_thread", { threadId }); + } + + /** + * Create a new draft email. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options (cc, bcc, htmlBody, etc.) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraft(to, subject, body, options = {}) { + return this.request("create_draft", { to, subject, body, ...options }); + } + + /** + * Create a draft reply to an existing thread. + * @param {string} threadId - The thread ID to reply to + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraftReply(threadId, body, replyAll = false, options = {}) { + return this.request("create_draft_reply", { + threadId, + body, + replyAll, + ...options, + }); + } + + /** + * Update an existing draft. + * @param {string} draftId - The draft ID + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async updateDraft(draftId, to, subject, body, options = {}) { + return this.request("update_draft", { + draftId, + to, + subject, + body, + ...options, + }); + } + + /** + * Get a specific draft by ID. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getDraft(draftId) { + return this.request("get_draft", { draftId }); + } + + /** + * List all drafts. + * @param {number} limit - Maximum drafts to return + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async listDrafts(limit = 25) { + return this.request("list_drafts", { limit }); + } + + /** + * Delete a draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async deleteDraft(draftId) { + return this.request("delete_draft", { draftId }); + } + + /** + * Send an existing draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async sendDraft(draftId) { + return this.request("send_draft", { draftId }); + } + + /** + * Send an email immediately. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async sendEmail(to, subject, body, options = {}) { + return this.request("send_email", { to, subject, body, ...options }); + } + + /** + * Reply to a thread immediately. + * @param {string} threadId - The thread ID + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async replyToThread(threadId, body, replyAll = false, options = {}) { + return this.request("reply_to_thread", { + threadId, + body, + replyAll, + ...options, + }); + } + + /** + * Mark a thread as read. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async markRead(threadId) { + return this.request("mark_read", { threadId }); + } + + /** + * Mark a thread as unread. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async markUnread(threadId) { + return this.request("mark_unread", { threadId }); + } + + /** + * Move a thread to trash. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async moveToTrash(threadId) { + return this.request("move_to_trash", { threadId }); + } + + /** + * Archive a thread. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async moveToArchive(threadId) { + return this.request("move_to_archive", { threadId }); + } + + /** + * Move a thread to inbox. + * @param {string} threadId - The thread ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async moveToInbox(threadId) { + return this.request("move_to_inbox", { threadId }); + } + + /** + * Get mailbox statistics. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getMailboxStats() { + return this.request("get_mailbox_stats"); + } +} + +module.exports = new GmailBridge(); +module.exports.GmailBridge = GmailBridge; +module.exports.prepareAttachment = prepareAttachment; +module.exports.parseAttachment = parseAttachment; +module.exports.handleAttachments = handleAttachments; +module.exports.MAX_TOTAL_ATTACHMENT_SIZE = MAX_TOTAL_ATTACHMENT_SIZE; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js new file mode 100644 index 000000000..7b3cd9363 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-get-inbox.js @@ -0,0 +1,104 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailGetInbox = { + name: "gmail-get-inbox", + plugin: function () { + return { + name: "gmail-get-inbox", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get the inbox emails from Gmail. Returns the inbox emails with the details of the email. " + + "Supports optional query and limit parameters to filter the emails.", + examples: [ + { + prompt: "What's in my inbox?", + call: JSON.stringify({ + query: "", + limit: 10, + }), + }, + { + prompt: "Check my inbox for any emails from John Doe", + call: JSON.stringify({ + query: "from:john.doe@example.com", + limit: 10, + }), + }, + { + prompt: "Get my 5 most recent unread emails", + call: JSON.stringify({ + query: "is:unread newer_than:1d", + limit: 5, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: + "Optional Gmail search query. Use Gmail query syntax like 'is:inbox', 'is:unread', 'from:email', 'subject:keyword', etc.", + }, + limit: { + type: "number", + description: + "Optional maximum number of results to return (1-50). Defaults to 10.", + default: 10, + }, + }, + required: [], + additionalProperties: false, + }, + handler: async function ({ query = "", limit = 10 }) { + try { + this.super.handlerProps.log(`Using the gmail-get-inbox tool.`); + this.super.introspect( + `${this.caller}: Searching Gmail with query "${query}"` + ); + + let searchQuery = `is:inbox`; + if (query) searchQuery += ` ${query}`; + const result = await gmailLib.search(searchQuery, limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Gmail get inbox failed - ${result.error}` + ); + return `Error getting inbox from Gmail: ${result.error}`; + } + + const { threads, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} emails in inbox` + ); + + if (resultCount === 0) { + return `No emails found in inbox.`; + } + + const summary = threads + .map( + (t, i) => + `${i + 1}. [${t.isUnread ? "UNREAD" : "READ"}] "${t.subject}" (ID: ${t.id}, ${t.messageCount} messages, Last: ${new Date(t.lastMessageDate).toLocaleString()})` + ) + .join("\n"); + + return `Found ${resultCount} email threads:\n\n${summary}\n\nAlways include the full thread ID in the response. Use the thread ID with gmail-read-thread to read the full conversation.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-get-inbox error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting inbox from Gmail: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js new file mode 100644 index 000000000..05cdcc328 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-read-thread.js @@ -0,0 +1,122 @@ +const gmailLib = require("../lib.js"); +const { handleAttachments } = require("../lib.js"); + +module.exports.GmailReadThread = { + name: "gmail-read-thread", + plugin: function () { + return { + name: "gmail-read-thread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Read a full email thread by its ID. Returns all messages in the thread " + + "including sender, recipients, subject, body, date, and attachment information. " + + "Use this after searching to read the full conversation.", + examples: [ + { + prompt: "Read the email thread with ID 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to read.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log(`Using the gmail-read-thread tool.`); + + if (!threadId) { + return "Error: threadId is required."; + } + + this.super.introspect( + `${this.caller}: Reading Gmail thread ${threadId}` + ); + + const result = await gmailLib.readThread(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to read thread - ${result.error}` + ); + return `Error reading Gmail thread: ${result.error}`; + } + + const thread = result.data; + const labels = thread.labels?.length + ? `Labels: ${thread.labels.join(", ")}` + : "No labels"; + + const { allAttachments, parsedContent: parsedAttachmentContent } = + await handleAttachments(this, thread.messages); + + const messagesFormatted = thread.messages + .map((msg, i) => { + let attachmentInfo = ""; + if (msg.attachments?.length > 0) { + attachmentInfo = `\n Attachments: ${msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(1)}KB)`).join(", ")}`; + } + return ( + `--- Message ${i + 1} ---\n` + + `From: ${msg.from}\n` + + `To: ${msg.to}\n` + + (msg.cc ? `CC: ${msg.cc}\n` : "") + + `Date: ${new Date(msg.date).toLocaleString()}\n` + + `Subject: ${msg.subject}\n` + + `Status: ${msg.isUnread ? "UNREAD" : "READ"}${msg.isStarred ? ", STARRED" : ""}\n` + + `\n${msg.body}` + + attachmentInfo + ); + }) + .join("\n\n"); + + this.super.introspect( + `${this.caller}: Successfully read thread with ${thread.messageCount} messages` + ); + + this.super.addCitation?.({ + id: `gmail-thread-${thread.id}`, + title: thread.subject, + text: messagesFormatted, + chunkSource: `gmail-thread://${thread.permalink}`, + score: null, + }); + + return ( + `Thread: "${thread.subject}"\n` + + `Thread ID: ${thread.id}\n` + + `Messages: ${thread.messageCount}\n` + + `Total Attachments: ${allAttachments.length}\n` + + `Status: ${thread.isUnread ? "UNREAD" : "READ"}${thread.isImportant ? ", IMPORTANT" : ""}${thread.hasStarredMessages ? ", HAS STARRED" : ""}\n` + + `Location: ${thread.isInInbox ? "Inbox" : ""}${thread.isInSpam ? "Spam" : ""}${thread.isInTrash ? "Trash" : ""}\n` + + `${labels}\n` + + `Permalink: ${thread.permalink}\n\n` + + messagesFormatted + + parsedAttachmentContent + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-read-thread error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error reading Gmail thread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js b/server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js new file mode 100644 index 000000000..8c9c21de6 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/search/gmail-search.js @@ -0,0 +1,121 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailSearch = { + name: "gmail-search", + plugin: function () { + return { + name: "gmail-search", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Search emails in Gmail using Gmail query syntax. " + + "Supports full Gmail search including keywords and operators combined. " + + "Common operators: 'is:inbox', 'is:unread', 'is:starred', 'from:email', 'to:email', " + + "'subject:word', 'has:attachment', 'newer_than:7d', 'older_than:1m'. " + + "Combine with search terms: 'is:inbox meeting notes' finds inbox emails containing 'meeting notes'. " + + "Returns thread summaries with ID, subject, date, and unread status.", + examples: [ + { + prompt: "Search for unread emails in my inbox about the project", + call: JSON.stringify({ + query: "is:inbox is:unread project update", + limit: 10, + }), + }, + { + prompt: "Find emails from john@example.com about meetings", + call: JSON.stringify({ + query: "from:john@example.com meeting", + limit: 20, + }), + }, + { + prompt: + "Search for emails with attachments from last week about invoices", + call: JSON.stringify({ + query: "has:attachment newer_than:7d invoice", + limit: 15, + }), + }, + { + prompt: "Find starred emails about budget", + call: JSON.stringify({ + query: "is:starred budget", + limit: 10, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: + "Gmail search query. Use Gmail query syntax like 'is:inbox', 'is:unread', 'from:email', 'subject:keyword', etc.", + }, + limit: { + type: "number", + description: + "Maximum number of results to return (1-50). Defaults to 10.", + default: 10, + }, + start: { + type: "number", + description: "Starting offset for pagination. Defaults to 0.", + default: 0, + }, + }, + required: ["query"], + additionalProperties: false, + }, + handler: async function ({ + query = "is:inbox", + limit = 10, + start = 0, + }) { + try { + this.super.handlerProps.log(`Using the gmail-search tool.`); + this.super.introspect( + `${this.caller}: Searching Gmail with query "${query}"` + ); + + const result = await gmailLib.search(query, limit, start); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Gmail search failed - ${result.error}` + ); + return `Error searching Gmail: ${result.error}`; + } + + const { threads, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} email threads matching query` + ); + + if (resultCount === 0) { + return `No emails found matching query "${query}".`; + } + + const summary = threads + .map( + (t, i) => + `${i + 1}. [${t.isUnread ? "UNREAD" : "READ"}] "${t.subject}" (ID: ${t.id}, ${t.messageCount} messages, Last: ${new Date(t.lastMessageDate).toLocaleString()})` + ) + .join("\n"); + + return `Found ${resultCount} email threads:\n\n${summary}\n\nAlways include the full thread ID in the response. Use the thread ID with gmail-read-thread to read the full conversation.`; + } catch (e) { + this.super.handlerProps.log(`gmail-search error: ${e.message}`); + this.super.introspect(`Error: ${e.message}`); + return `Error searching Gmail: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js b/server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js new file mode 100644 index 000000000..56cf5f9c7 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/send/gmail-reply-to-thread.js @@ -0,0 +1,238 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailReplyToThread = { + name: "gmail-reply-to-thread", + plugin: function () { + return { + name: "gmail-reply-to-thread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Reply to an existing email thread immediately. " + + "This action sends the reply right away and cannot be undone. " + + "For composing replies that need review before sending, use gmail-create-draft-reply instead. " + + "Supports file attachments via absolute file paths (max 20MB total for all attachments combined).", + examples: [ + { + prompt: "Reply to thread 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thank you for your email. I've reviewed the proposal and have some feedback.", + replyAll: false, + }), + }, + { + prompt: "Reply all to the thread", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Thanks everyone. I agree with the proposed timeline.", + replyAll: true, + }), + }, + { + prompt: "Reply with an attachment", + call: JSON.stringify({ + threadId: "18abc123def", + body: "Please find the requested document attached.", + attachments: ["/Users/me/Documents/document.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to reply to.", + }, + body: { + type: "string", + description: "Plain text reply body content.", + }, + replyAll: { + type: "boolean", + description: + "Whether to reply to all recipients. Defaults to false (reply to sender only).", + default: false, + }, + cc: { + type: "string", + description: + "Additional CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the reply body. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the reply.", + }, + }, + required: ["threadId", "body"], + additionalProperties: false, + }, + handler: async function ({ + threadId, + body, + replyAll = false, + cc, + bcc, + htmlBody, + attachments, + }) { + try { + this.super.handlerProps.log( + `Using the gmail-reply-to-thread tool.` + ); + + if (!threadId || !body) { + return "Error: 'threadId' and 'body' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + fileName: result.fileInfo.name, + fileSize: result.fileInfo.sizeFormatted, + filePath: result.fileInfo.path, + }, + description: + `Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to reply? ` + + `This file will be sent immediately.`, + }); + + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected attaching "${result.fileInfo.name}"` + ); + return `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`; + } + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + threadId, + replyAll, + attachmentCount: preparedAttachments.length, + }, + description: `Reply to thread "${threadId}"${replyAll ? " (reply all)" : ""}${attachmentNote} - This will send immediately`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Replying to thread ${threadId}${replyAll ? " (reply all)" : ""}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.replyToThread( + threadId, + body, + replyAll, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to reply to thread - ${result.error}` + ); + return `Error replying to thread: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully replied to thread ${threadId}` + ); + + return ( + `Successfully replied to thread:\n` + + `Thread ID: ${threadId}\n` + + `Reply Type: ${replyAll ? "Reply All" : "Reply"}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe reply has been sent.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-reply-to-thread error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error replying to thread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js b/server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js new file mode 100644 index 000000000..7b1cdde37 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/send/gmail-send-email.js @@ -0,0 +1,242 @@ +const gmailLib = require("../lib.js"); +const { prepareAttachment, MAX_TOTAL_ATTACHMENT_SIZE } = require("../lib.js"); +const { humanFileSize } = require("../../../../../helpers"); + +module.exports.GmailSendEmail = { + name: "gmail-send-email", + plugin: function () { + return { + name: "gmail-send-email", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an email immediately through Gmail. " + + "This action sends the email right away and cannot be undone. " + + "For composing emails that need review before sending, use gmail-create-draft instead.", + examples: [ + { + prompt: "Send an email to john@example.com about the project", + call: JSON.stringify({ + to: "john@example.com", + subject: "Project Update", + body: "Hi John,\n\nHere's the latest update on the project.\n\nBest regards", + }), + }, + { + prompt: "Send an email with CC recipients", + call: JSON.stringify({ + to: "john@example.com", + subject: "Meeting Notes", + body: "Please find the meeting notes attached.", + cc: "manager@example.com, team@example.com", + }), + }, + { + prompt: "Send an email with an attachment", + call: JSON.stringify({ + to: "john@example.com", + subject: "Report", + body: "Please find the report attached.", + attachments: ["/Users/me/Documents/report.pdf"], + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + to: { + type: "string", + description: + "Recipient email address(es). Multiple addresses can be comma-separated.", + }, + subject: { + type: "string", + description: "Email subject line.", + }, + body: { + type: "string", + description: "Plain text email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + htmlBody: { + type: "string", + description: "HTML version of the email body. Optional.", + }, + replyTo: { + type: "string", + description: "Reply-to email address. Optional.", + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the email.", + }, + }, + required: ["to", "subject", "body"], + additionalProperties: false, + }, + handler: async function ({ + to, + subject, + body, + cc, + bcc, + htmlBody, + replyTo, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the gmail-send-email tool.`); + + if (!to || !subject) { + return "Error: 'to' and 'subject' are required."; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + if (Array.isArray(attachments) && attachments.length > 0) { + this.super.introspect( + `${this.caller}: Validating ${attachments.length} attachment(s)...` + ); + + for (const filePath of attachments) { + const result = prepareAttachment(filePath); + if (!result.success) { + this.super.introspect( + `${this.caller}: Attachment validation failed - ${result.error}` + ); + return `Error with attachment: ${result.error}`; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize( + totalAttachmentSize, + true + ); + this.super.introspect( + `${this.caller}: Total attachment size (${totalFormatted}) exceeds 20MB limit` + ); + return `Error: Total attachment size (${totalFormatted}) exceeds the 20MB limit. Please reduce the number or size of attachments.`; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + fileName: result.fileInfo.name, + fileSize: result.fileInfo.sizeFormatted, + filePath: result.fileInfo.path, + }, + description: + `Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to email? ` + + `This file will be sent to ${to}.`, + }); + + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected attaching "${result.fileInfo.name}"` + ); + return `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`; + } + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + this.super.introspect( + `${this.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + } + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description: `Send email to "${to}" with subject "${subject}"${attachmentNote} - This will send immediately`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Sending email to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = {}; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (htmlBody) options.htmlBody = htmlBody; + if (replyTo) options.replyTo = replyTo; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + const result = await gmailLib.sendEmail( + to, + subject, + body, + options + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send email - ${result.error}` + ); + return `Error sending email: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully sent email to ${to}` + ); + + return ( + `Successfully sent email:\n` + + `To: ${to}\n` + + `Subject: ${subject}\n` + + (cc ? `CC: ${cc}\n` : "") + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe email has been sent.` + ); + } catch (e) { + this.super.handlerProps.log( + `gmail-send-email error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error sending email: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js new file mode 100644 index 000000000..3b95ac4b1 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-read.js @@ -0,0 +1,87 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMarkRead = { + name: "gmail-mark-read", + plugin: function () { + return { + name: "gmail-mark-read", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Mark an email thread as read in Gmail. " + + "This will mark all messages in the thread as read.", + examples: [ + { + prompt: "Mark thread 18abc123def as read", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to mark as read.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log(`Using the gmail-mark-read tool.`); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Mark Gmail thread "${threadId}" as read`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Marking thread ${threadId} as read` + ); + + const result = await gmailLib.markRead(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to mark thread as read - ${result.error}` + ); + return `Error marking thread as read: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully marked thread ${threadId} as read` + ); + + return `Successfully marked thread ${threadId} as read.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-mark-read error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error marking thread as read: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js new file mode 100644 index 000000000..ada7013e8 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-mark-unread.js @@ -0,0 +1,87 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMarkUnread = { + name: "gmail-mark-unread", + plugin: function () { + return { + name: "gmail-mark-unread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Mark an email thread as unread in Gmail. " + + "This will mark the thread as unread so it appears as a new message.", + examples: [ + { + prompt: "Mark thread 18abc123def as unread", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to mark as unread.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log(`Using the gmail-mark-unread tool.`); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Mark Gmail thread "${threadId}" as unread`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Marking thread ${threadId} as unread` + ); + + const result = await gmailLib.markUnread(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to mark thread as unread - ${result.error}` + ); + return `Error marking thread as unread: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully marked thread ${threadId} as unread` + ); + + return `Successfully marked thread ${threadId} as unread.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-mark-unread error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error marking thread as unread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js new file mode 100644 index 000000000..b275b907b --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-archive.js @@ -0,0 +1,89 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMoveToArchive = { + name: "gmail-move-to-archive", + plugin: function () { + return { + name: "gmail-move-to-archive", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Archive an email thread in Gmail. " + + "The thread will be removed from inbox but can still be found in All Mail or by searching.", + examples: [ + { + prompt: "Archive thread 18abc123def", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to archive.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log( + `Using the gmail-move-to-archive tool.` + ); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Archive Gmail thread "${threadId}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Archiving thread ${threadId}` + ); + + const result = await gmailLib.moveToArchive(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to archive thread - ${result.error}` + ); + return `Error archiving thread: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully archived thread ${threadId}` + ); + + return `Successfully archived thread ${threadId}. It can still be found in All Mail or by searching.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-move-to-archive error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error archiving thread: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js new file mode 100644 index 000000000..76b7b866b --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-inbox.js @@ -0,0 +1,89 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMoveToInbox = { + name: "gmail-move-to-inbox", + plugin: function () { + return { + name: "gmail-move-to-inbox", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Move an email thread back to inbox in Gmail. " + + "Use this to unarchive a thread or move it from other locations to inbox.", + examples: [ + { + prompt: "Move thread 18abc123def to inbox", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to move to inbox.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log( + `Using the gmail-move-to-inbox tool.` + ); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Move Gmail thread "${threadId}" to inbox`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Moving thread ${threadId} to inbox` + ); + + const result = await gmailLib.moveToInbox(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to move thread to inbox - ${result.error}` + ); + return `Error moving thread to inbox: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully moved thread ${threadId} to inbox` + ); + + return `Successfully moved thread ${threadId} to inbox.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-move-to-inbox error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error moving thread to inbox: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js new file mode 100644 index 000000000..fe65ae006 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/gmail/threads/gmail-move-to-trash.js @@ -0,0 +1,89 @@ +const gmailLib = require("../lib.js"); + +module.exports.GmailMoveToTrash = { + name: "gmail-move-to-trash", + plugin: function () { + return { + name: "gmail-move-to-trash", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Move an email thread to trash in Gmail. " + + "The thread can be recovered from trash within 30 days.", + examples: [ + { + prompt: "Move thread 18abc123def to trash", + call: JSON.stringify({ + threadId: "18abc123def", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + threadId: { + type: "string", + description: "The Gmail thread ID to move to trash.", + }, + }, + required: ["threadId"], + additionalProperties: false, + }, + handler: async function ({ threadId }) { + try { + this.super.handlerProps.log( + `Using the gmail-move-to-trash tool.` + ); + + if (!threadId) { + return "Error: 'threadId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { threadId }, + description: `Move Gmail thread "${threadId}" to trash`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Moving thread ${threadId} to trash` + ); + + const result = await gmailLib.moveToTrash(threadId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to move thread to trash - ${result.error}` + ); + return `Error moving thread to trash: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully moved thread ${threadId} to trash` + ); + + return `Successfully moved thread ${threadId} to trash. It can be recovered within 30 days.`; + } catch (e) { + this.super.handlerProps.log( + `gmail-move-to-trash error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error moving thread to trash: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js new file mode 100644 index 000000000..3b1b649c1 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-get-calendar.js @@ -0,0 +1,79 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetCalendar = { + name: "gcal-get-calendar", + plugin: function () { + return { + name: "gcal-get-calendar", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get detailed information about a specific Google Calendar by its ID. " + + "Returns calendar name, description, time zone, and settings.", + examples: [ + { + prompt: "Get details for my work calendar", + call: JSON.stringify({ + calendarId: "work@group.calendar.google.com", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + calendarId: { + type: "string", + description: + "The calendar ID. If omitted, returns the primary calendar.", + }, + }, + additionalProperties: false, + }, + handler: async function ({ calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-get-calendar tool.`); + this.super.introspect( + `${this.caller}: Fetching calendar details${calendarId ? ` for ${calendarId}` : " for primary calendar"}...` + ); + + const result = await googleCalendarLib.getCalendar(calendarId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get calendar - ${result.error}` + ); + return `Error getting calendar: ${result.error}`; + } + + const cal = result.data; + this.super.introspect( + `${this.caller}: Retrieved calendar "${cal.name}"` + ); + + return ( + `Calendar Details:\n` + + `Name: ${cal.name}\n` + + `ID: ${cal.calendarId}\n` + + `Description: ${cal.description || "(none)"}\n` + + `Time Zone: ${cal.timeZone}\n` + + `Primary: ${cal.isPrimary ? "Yes" : "No"}\n` + + `Owned by me: ${cal.isOwnedByMe ? "Yes" : "No"}\n` + + `Hidden: ${cal.isHidden ? "Yes" : "No"}\n` + + `Selected: ${cal.isSelected ? "Yes" : "No"}` + ); + } catch (e) { + this.super.handlerProps.log( + `gcal-get-calendar error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting calendar: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js new file mode 100644 index 000000000..019eef6d5 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/calendars/gcal-list-calendars.js @@ -0,0 +1,82 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalListCalendars = { + name: "gcal-list-calendars", + plugin: function () { + return { + name: "gcal-list-calendars", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all Google Calendars the user owns or is subscribed to. " + + "Returns calendar names, IDs, time zones, and ownership information. " + + "Use this to discover available calendars before querying events.", + examples: [ + { + prompt: "What calendars do I have?", + call: JSON.stringify({}), + }, + { + prompt: "Show me all my Google Calendars", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + try { + this.super.handlerProps.log( + `Using the gcal-list-calendars tool.` + ); + this.super.introspect( + `${this.caller}: Fetching list of Google Calendars...` + ); + + const result = await googleCalendarLib.listCalendars(); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to list calendars - ${result.error}` + ); + return `Error listing calendars: ${result.error}`; + } + + const { totalCalendars, calendars } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalCalendars} calendar(s)` + ); + + if (totalCalendars === 0) { + return "No calendars found."; + } + + const summary = calendars + .map( + (cal, i) => + `${i + 1}. "${cal.name}"${cal.isPrimary ? " (Primary)" : ""}\n` + + ` ID: ${cal.calendarId}\n` + + ` Time Zone: ${cal.timeZone}\n` + + ` Owned by me: ${cal.isOwnedByMe ? "Yes" : "No"}` + ) + .join("\n\n"); + + return `Found ${totalCalendars} calendar(s):\n\n${summary}`; + } catch (e) { + this.super.handlerProps.log( + `gcal-list-calendars error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error listing calendars: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js new file mode 100644 index 000000000..385347144 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-create-event.js @@ -0,0 +1,233 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalCreateEvent = { + name: "gcal-create-event", + plugin: function () { + return { + name: "gcal-create-event", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a calendar event with full control over all event properties. " + + "Supports timed events, all-day events, and recurring events. " + + "For simple events, consider using gcal-quick-add instead.", + examples: [ + { + prompt: + "Create a meeting called 'Team Standup' tomorrow from 9am to 9:30am", + call: JSON.stringify({ + title: "Team Standup", + startTime: "2025-01-16T09:00:00", + endTime: "2025-01-16T09:30:00", + }), + }, + { + prompt: "Create an all-day event for my birthday on March 15th", + call: JSON.stringify({ + title: "My Birthday", + allDay: true, + date: "2025-03-15", + }), + }, + { + prompt: "Create a weekly team meeting every Monday at 10am", + call: JSON.stringify({ + title: "Weekly Team Meeting", + startTime: "2025-01-20T10:00:00", + endTime: "2025-01-20T11:00:00", + recurrence: { + frequency: "weekly", + daysOfWeek: ["MONDAY"], + }, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + title: { + type: "string", + description: "The event title.", + }, + startTime: { + type: "string", + description: + "Start time in ISO datetime format (for timed events).", + }, + endTime: { + type: "string", + description: + "End time in ISO datetime format (for timed events).", + }, + allDay: { + type: "boolean", + description: "Set to true for all-day events.", + }, + date: { + type: "string", + description: + "Date in YYYY-MM-DD format (required for all-day events).", + }, + endDate: { + type: "string", + description: + "End date for multi-day all-day events (YYYY-MM-DD).", + }, + description: { + type: "string", + description: "Event description/notes.", + }, + location: { + type: "string", + description: "Event location.", + }, + guests: { + type: "array", + items: { type: "string" }, + description: "Array of guest email addresses.", + }, + sendInvites: { + type: "boolean", + description: "Whether to send invite emails to guests.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + recurrence: { + type: "object", + description: "Recurrence configuration for recurring events.", + properties: { + frequency: { + type: "string", + enum: ["daily", "weekly", "monthly", "yearly"], + description: "How often the event repeats.", + }, + interval: { + type: "number", + description: "Repeat every N periods (default 1).", + }, + count: { + type: "number", + description: "Number of occurrences.", + }, + until: { + type: "string", + description: "ISO date to end recurrence.", + }, + daysOfWeek: { + type: "array", + items: { type: "string" }, + description: + "For weekly recurrence: days like ['MONDAY', 'WEDNESDAY'].", + }, + }, + }, + }, + required: ["title"], + additionalProperties: false, + }, + handler: async function (params) { + try { + this.super.handlerProps.log(`Using the gcal-create-event tool.`); + + if (this.super.requestToolApproval) { + let timeInfo; + if (params.allDay) { + timeInfo = `All-day event on ${params.date}`; + if (params.endDate) timeInfo += ` to ${params.endDate}`; + } else { + timeInfo = `${params.startTime} - ${params.endTime}`; + } + const guestInfo = + params.guests?.length > 0 + ? ` with ${params.guests.length} guest(s): ${params.guests.join(", ")}` + : ""; + const recurrenceInfo = params.recurrence + ? ` (recurring: ${params.recurrence.frequency})` + : ""; + + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + title: params.title, + time: timeInfo, + guests: params.guests || [], + location: params.location, + recurrence: params.recurrence, + }, + description: `Create calendar event "${params.title}" - ${timeInfo}${guestInfo}${recurrenceInfo}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating event "${params.title}"...` + ); + + const result = await googleCalendarLib.createEvent(params); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create event - ${result.error}` + ); + return `Error creating event: ${result.error}`; + } + + const isRecurring = !!result.data.eventSeries; + const eventData = result.data.event || result.data.eventSeries; + + this.super.introspect( + `${this.caller}: Created ${isRecurring ? "recurring " : ""}event "${eventData.title}"` + ); + + let timeInfo; + if (params.allDay) { + timeInfo = `All-day event on ${params.date}`; + if (params.endDate) { + timeInfo += ` to ${params.endDate}`; + } + } else { + timeInfo = `${new Date(params.startTime).toLocaleString()} - ${new Date(params.endTime).toLocaleString()}`; + } + + let response = + `Event created successfully!\n\n` + + `Title: ${eventData.title}\n` + + `${timeInfo}\n` + + `${isRecurring ? "Event Series" : "Event"} ID: ${isRecurring ? eventData.eventSeriesId : eventData.eventId}\n` + + `Calendar ID: ${eventData.calendarId}`; + + if (params.location) { + response += `\nLocation: ${params.location}`; + } + if (params.guests?.length > 0) { + response += `\nGuests: ${params.guests.join(", ")}`; + } + if (isRecurring) { + response += `\nRecurrence: ${params.recurrence.frequency}`; + } + + return response; + } catch (e) { + this.super.handlerProps.log( + `gcal-create-event error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error creating event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js new file mode 100644 index 000000000..3aa3a3fec --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-event.js @@ -0,0 +1,116 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetEvent = { + name: "gcal-get-event", + plugin: function () { + return { + name: "gcal-get-event", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get detailed information about a specific calendar event by its ID. " + + "Returns event title, time, location, description, guests, and RSVP status.", + examples: [ + { + prompt: "Get details for event abc123", + call: JSON.stringify({ eventId: "abc123" }), + }, + { + prompt: "Show me the meeting details for event xyz789", + call: JSON.stringify({ eventId: "xyz789" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + eventId: { + type: "string", + description: "The event ID to retrieve.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["eventId"], + additionalProperties: false, + }, + handler: async function ({ eventId, calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-get-event tool.`); + this.super.introspect( + `${this.caller}: Fetching event ${eventId}...` + ); + + const result = await googleCalendarLib.getEvent( + eventId, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get event - ${result.error}` + ); + return `Error getting event: ${result.error}`; + } + + const event = result.data; + this.super.introspect( + `${this.caller}: Retrieved event "${event.title}"` + ); + + let timeInfo; + if (event.isAllDayEvent) { + timeInfo = `All-day event: ${new Date(event.startDate).toLocaleDateString()}`; + if (event.endDate) { + timeInfo += ` to ${new Date(event.endDate).toLocaleDateString()}`; + } + } else { + timeInfo = `Time: ${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`; + } + + const guestList = + event.guests?.length > 0 + ? event.guests + .map((g) => ` - ${g.name || g.email} (${g.status})`) + .join("\n") + : " (none)"; + + const eventDetails = + `Event Details:\n` + + `Title: ${event.title}\n` + + `Event ID: ${event.eventId}\n` + + `Calendar ID: ${event.calendarId}\n` + + `${timeInfo}\n` + + `Location: ${event.location || "(none)"}\n` + + `Description: ${event.description || "(none)"}\n` + + `Recurring: ${event.isRecurringEvent ? "Yes" : "No"}\n` + + `My Status: ${event.myStatus}\n` + + `Owned by me: ${event.isOwnedByMe ? "Yes" : "No"}\n` + + `Guests:\n${guestList}\n` + + `Created: ${new Date(event.dateCreated).toLocaleString()}\n` + + `Last Updated: ${new Date(event.lastUpdated).toLocaleString()}`; + + this.super.addCitation?.({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + return eventDetails; + } catch (e) { + this.super.handlerProps.log(`gcal-get-event error: ${e.message}`); + this.super.introspect(`Error: ${e.message}`); + return `Error getting event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js new file mode 100644 index 000000000..d76c46e37 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events-for-day.js @@ -0,0 +1,117 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetEventsForDay = { + name: "gcal-get-events-for-day", + plugin: function () { + return { + name: "gcal-get-events-for-day", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get all calendar events for a specific day. " + + "Returns event titles, times, and IDs for the specified date.", + examples: [ + { + prompt: "What events do I have on January 15th, 2025?", + call: JSON.stringify({ date: "2025-01-15" }), + }, + { + prompt: "Show my schedule for March 20th", + call: JSON.stringify({ date: "2025-03-20" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + date: { + type: "string", + description: "The date to query in ISO format (YYYY-MM-DD).", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["date"], + additionalProperties: false, + }, + handler: async function ({ date, calendarId }) { + try { + this.super.handlerProps.log( + `Using the gcal-get-events-for-day tool.` + ); + this.super.introspect( + `${this.caller}: Fetching events for ${date}...` + ); + + const result = await googleCalendarLib.getEventsForDay( + date, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get events - ${result.error}` + ); + return `Error getting events: ${result.error}`; + } + + const { eventCount, events } = result.data; + this.super.introspect( + `${this.caller}: Found ${eventCount} event(s) for ${date}` + ); + + if (eventCount === 0) return `No events scheduled for ${date}.`; + + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = "All day"; + } else { + const start = new Date(event.startTime).toLocaleTimeString( + [], + { hour: "2-digit", minute: "2-digit" } + ); + const end = new Date(event.endTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${start} - ${end}`; + } + const eventDetails = + `${i + 1}. "${event.title}" (${timeStr})\n` + + ` ID: ${event.eventId}` + + (event.location ? `\n Location: ${event.location}` : ""); + + summaries.push(eventDetails); + citations.push({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + }); + + const summary = summaries.join("\n\n"); + citations.forEach((c) => this.super.addCitation?.(c)); + return `Events for ${date} (${eventCount} total):\n\n${summary}`; + } catch (e) { + this.super.handlerProps.log( + `gcal-get-events-for-day error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting events: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js new file mode 100644 index 000000000..d633bd091 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-events.js @@ -0,0 +1,159 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalGetEvents = { + name: "gcal-get-events", + plugin: function () { + return { + name: "gcal-get-events", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get calendar events within a date range, optionally filtered by search query. " + + "Returns event titles, times, and IDs. Use this for custom date ranges.", + examples: [ + { + prompt: "Find all meetings next week", + call: JSON.stringify({ + startDate: "2025-01-20T00:00:00", + endDate: "2025-01-27T23:59:59", + query: "meeting", + limit: 25, + }), + }, + { + prompt: "Show events from January 1st to January 31st", + call: JSON.stringify({ + startDate: "2025-01-01T00:00:00", + endDate: "2025-01-31T23:59:59", + limit: 50, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + startDate: { + type: "string", + description: "Start of date range in ISO datetime format.", + }, + endDate: { + type: "string", + description: "End of date range in ISO datetime format.", + }, + query: { + type: "string", + description: "Optional text to search for in events.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + limit: { + type: "number", + description: "Max results to return (default 25, max 100).", + default: 25, + }, + }, + required: ["startDate", "endDate"], + additionalProperties: false, + }, + handler: async function ({ + startDate, + endDate, + query, + calendarId, + limit = 25, + }) { + try { + this.super.handlerProps.log(`Using the gcal-get-events tool.`); + this.super.introspect( + `${this.caller}: Fetching events from ${startDate} to ${endDate}${query ? ` matching "${query}"` : ""}...` + ); + + const result = await googleCalendarLib.getEvents( + startDate, + endDate, + calendarId, + query, + limit + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get events - ${result.error}` + ); + return `Error getting events: ${result.error}`; + } + + const { totalEvents, returnedEvents, events } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalEvents} event(s), returning ${returnedEvents}` + ); + + if (totalEvents === 0) { + return `No events found between ${startDate} and ${endDate}${query ? ` matching "${query}"` : ""}.`; + } + + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; + } else { + const startTime = new Date(event.startTime); + const endTime = new Date(event.endTime); + const dateStr = startTime.toLocaleDateString(); + const startTimeStr = startTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const endTimeStr = endTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${dateStr} ${startTimeStr} - ${endTimeStr}`; + } + const eventDetails = + `${i + 1}. "${event.title}"\n` + + ` ${timeStr}\n` + + ` ID: ${event.eventId}` + + (event.location ? `\n Location: ${event.location}` : ""); + + summaries.push(eventDetails); + citations.push({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + }); + + const summary = summaries.join("\n\n"); + citations.forEach((c) => this.super.addCitation?.(c)); + + let response = `Found ${totalEvents} event(s)`; + if (returnedEvents < totalEvents) { + response += ` (showing ${returnedEvents})`; + } + response += `:\n\n${summary}`; + + return response; + } catch (e) { + this.super.handlerProps.log( + `gcal-get-events error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting events: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js new file mode 100644 index 000000000..a0b4ea013 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-get-upcoming-events.js @@ -0,0 +1,249 @@ +const googleCalendarLib = require("../lib.js"); + +/** + * Helper to compute date ranges for common time periods. + * @param {string} period - "today", "week", or "month" + * @returns {{startDate: string, endDate: string, label: string}} + */ +function getDateRangeForPeriod(period) { + const now = new Date(); + const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + let startDate, endDate, label; + + switch (period.toLowerCase()) { + case "today": + startDate = new Date(today); + endDate = new Date(today); + endDate.setHours(23, 59, 59, 999); + label = "today"; + break; + + case "tomorrow": + startDate = new Date(today); + startDate.setDate(startDate.getDate() + 1); + endDate = new Date(startDate); + endDate.setHours(23, 59, 59, 999); + label = "tomorrow"; + break; + + case "week": + case "this week": + startDate = new Date(today); + endDate = new Date(today); + endDate.setDate(endDate.getDate() + 7); + endDate.setHours(23, 59, 59, 999); + label = "the next 7 days"; + break; + + case "next week": + startDate = new Date(today); + startDate.setDate(startDate.getDate() + 7); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 7); + endDate.setHours(23, 59, 59, 999); + label = "next week"; + break; + + case "month": + case "this month": + startDate = new Date(today); + endDate = new Date(today); + endDate.setDate(endDate.getDate() + 30); + endDate.setHours(23, 59, 59, 999); + label = "the next 30 days"; + break; + + case "next month": + startDate = new Date(today); + startDate.setDate(startDate.getDate() + 30); + endDate = new Date(startDate); + endDate.setDate(endDate.getDate() + 30); + endDate.setHours(23, 59, 59, 999); + label = "next month"; + break; + + default: + throw new Error( + `Invalid period: "${period}". Use "today", "tomorrow", "week", "this week", "next week", "month", "this month", or "next month".` + ); + } + + return { + startDate: startDate.toISOString(), + endDate: endDate.toISOString(), + label, + }; +} + +module.exports.GCalGetUpcomingEvents = { + name: "gcal-get-upcoming-events", + plugin: function () { + return { + name: "gcal-get-upcoming-events", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get upcoming calendar events using simple time period keywords. " + + "This is the preferred tool for common queries like 'what's on my calendar today?' " + + "Supports periods: 'today', 'tomorrow', 'week' (next 7 days), 'month' (next 30 days), " + + "'next week', and 'next month'. " + + "Automatically calculates the correct date range so you don't need to know the current date.", + examples: [ + { + prompt: "What's on my calendar today?", + call: JSON.stringify({ period: "today" }), + }, + { + prompt: "What meetings do I have this week?", + call: JSON.stringify({ period: "week", query: "meeting" }), + }, + { + prompt: "Show me my schedule for the month", + call: JSON.stringify({ period: "month", limit: 50 }), + }, + { + prompt: "What do I have tomorrow?", + call: JSON.stringify({ period: "tomorrow" }), + }, + { + prompt: "Find all project events next week", + call: JSON.stringify({ period: "next week", query: "project" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + period: { + type: "string", + description: + "Time period to query. Options: 'today', 'tomorrow', 'week' (next 7 days), 'this week', 'next week', 'month' (next 30 days), 'this month', 'next month'.", + enum: [ + "today", + "tomorrow", + "week", + "this week", + "next week", + "month", + "this month", + "next month", + ], + }, + query: { + type: "string", + description: "Optional text to search for in events.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + limit: { + type: "number", + description: "Max results to return (default 25, max 100).", + default: 25, + }, + }, + required: ["period"], + additionalProperties: false, + }, + handler: async function ({ period, query, calendarId, limit = 25 }) { + try { + this.super.handlerProps.log( + `Using the gcal-get-upcoming-events tool.` + ); + + const { startDate, endDate, label } = + getDateRangeForPeriod(period); + + this.super.introspect( + `${this.caller}: Fetching events for ${label}${query ? ` matching "${query}"` : ""}...` + ); + + const result = await googleCalendarLib.getEvents( + startDate, + endDate, + calendarId, + query, + limit + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get events - ${result.error}` + ); + return `Error getting events: ${result.error}`; + } + + const { totalEvents, returnedEvents, events } = result.data; + this.super.introspect( + `${this.caller}: Found ${totalEvents} event(s) for ${label}` + ); + + if (totalEvents === 0) { + return `No events scheduled for ${label}${query ? ` matching "${query}"` : ""}.`; + } + + const summaries = []; + const citations = []; + events.forEach((event, i) => { + let timeStr; + if (event.isAllDayEvent) { + timeStr = `All day (${new Date(event.startDate).toLocaleDateString()})`; + } else { + const startTime = new Date(event.startTime); + const endTime = new Date(event.endTime); + const dateStr = startTime.toLocaleDateString(); + const startTimeStr = startTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + const endTimeStr = endTime.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + }); + timeStr = `${dateStr} ${startTimeStr} - ${endTimeStr}`; + } + const eventDetails = + `${i + 1}. "${event.title}"\n` + + ` ${timeStr}\n` + + ` ID: ${event.eventId}` + + (event.location ? `\n Location: ${event.location}` : ""); + + summaries.push(eventDetails); + citations.push({ + id: `google-calendar-${event.eventId}`, + title: event.title, + text: eventDetails, + chunkSource: `google-calendar://${event.eventId}`, + score: null, + }); + }); + + const summary = summaries.join("\n\n"); + citations.forEach((c) => this.super.addCitation?.(c)); + + let response = `Events for ${label}`; + if (returnedEvents < totalEvents) { + response += ` (${returnedEvents} of ${totalEvents})`; + } else { + response += ` (${totalEvents} total)`; + } + response += `:\n\n${summary}`; + + return response; + } catch (e) { + this.super.handlerProps.log( + `gcal-get-upcoming-events error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error getting events: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js new file mode 100644 index 000000000..372972100 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-quick-add.js @@ -0,0 +1,114 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalQuickAdd = { + name: "gcal-quick-add", + plugin: function () { + return { + name: "gcal-quick-add", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a calendar event from a natural language description. " + + "Google Calendar will parse the description to extract the event title, date, and time. " + + "Examples: 'Meeting with John tomorrow at 3pm', 'Dentist appointment on Friday at 10am'.", + examples: [ + { + prompt: "Add a meeting with John tomorrow at 3pm", + call: JSON.stringify({ + description: "Meeting with John tomorrow at 3pm", + }), + }, + { + prompt: "Schedule lunch with Sarah next Tuesday at noon", + call: JSON.stringify({ + description: "Lunch with Sarah next Tuesday at noon", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + description: { + type: "string", + description: + "Natural language description of the event including title, date, and time.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["description"], + additionalProperties: false, + }, + handler: async function ({ description, calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-quick-add tool.`); + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + description, + calendarId, + }, + description: `Create calendar event from: "${description}"`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Creating event from "${description}"...` + ); + + const result = await googleCalendarLib.quickAdd( + description, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create event - ${result.error}` + ); + return `Error creating event: ${result.error}`; + } + + const event = result.data.event; + this.super.introspect( + `${this.caller}: Created event "${event.title}"` + ); + + let timeInfo; + if (event.isAllDayEvent) { + timeInfo = `All-day event on ${new Date(event.startDate).toLocaleDateString()}`; + } else { + timeInfo = `${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`; + } + + return ( + `Event created successfully!\n\n` + + `Title: ${event.title}\n` + + `${timeInfo}\n` + + `Event ID: ${event.eventId}\n` + + `Calendar ID: ${event.calendarId}` + ); + } catch (e) { + this.super.handlerProps.log(`gcal-quick-add error: ${e.message}`); + this.super.introspect(`Error: ${e.message}`); + return `Error creating event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js new file mode 100644 index 000000000..3de6f59d0 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-set-my-status.js @@ -0,0 +1,125 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalSetMyStatus = { + name: "gcal-set-my-status", + plugin: function () { + return { + name: "gcal-set-my-status", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Set your RSVP status for a calendar event. " + + "Use this to accept, decline, or tentatively accept meeting invitations.", + examples: [ + { + prompt: "Accept the meeting invitation for event abc123", + call: JSON.stringify({ eventId: "abc123", status: "YES" }), + }, + { + prompt: "Decline event xyz789", + call: JSON.stringify({ eventId: "xyz789", status: "NO" }), + }, + { + prompt: "Mark myself as maybe for event def456", + call: JSON.stringify({ eventId: "def456", status: "MAYBE" }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to RSVP to.", + }, + status: { + type: "string", + enum: ["YES", "NO", "MAYBE", "INVITED"], + description: + "Your RSVP status: YES (accept), NO (decline), MAYBE (tentative), or INVITED (reset to invited).", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + }, + required: ["eventId", "status"], + additionalProperties: false, + }, + handler: async function ({ eventId, status, calendarId }) { + try { + this.super.handlerProps.log(`Using the gcal-set-my-status tool.`); + + const statusActions = { + YES: "accept", + NO: "decline", + MAYBE: "tentatively accept", + INVITED: "reset to invited", + }; + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + eventId, + status, + }, + description: `Set RSVP status to ${status} (${statusActions[status] || status}) for event ${eventId}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Setting RSVP status to ${status} for event ${eventId}...` + ); + + const result = await googleCalendarLib.setMyStatus( + eventId, + status, + calendarId + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to set status - ${result.error}` + ); + return `Error setting RSVP status: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: RSVP status set to ${result.data.newStatus}` + ); + + const statusMessages = { + YES: "accepted", + NO: "declined", + MAYBE: "marked as tentative", + INVITED: "reset to invited", + }; + + return ( + `RSVP status updated!\n\n` + + `Event ID: ${result.data.eventId}\n` + + `Status: ${result.data.newStatus} (${statusMessages[result.data.newStatus] || result.data.newStatus})` + ); + } catch (e) { + this.super.handlerProps.log( + `gcal-set-my-status error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error setting RSVP status: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js new file mode 100644 index 000000000..e2e01a19d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/events/gcal-update-event.js @@ -0,0 +1,173 @@ +const googleCalendarLib = require("../lib.js"); + +module.exports.GCalUpdateEvent = { + name: "gcal-update-event", + plugin: function () { + return { + name: "gcal-update-event", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Update an existing calendar event. " + + "You can change the title, description, location, time, or guest list. " + + "Only provide the fields you want to update.", + examples: [ + { + prompt: "Change the title of event abc123 to 'Updated Meeting'", + call: JSON.stringify({ + eventId: "abc123", + title: "Updated Meeting", + }), + }, + { + prompt: "Move event xyz789 to 3pm", + call: JSON.stringify({ + eventId: "xyz789", + startTime: "2025-01-15T15:00:00", + endTime: "2025-01-15T16:00:00", + }), + }, + { + prompt: "Add a location to event abc123", + call: JSON.stringify({ + eventId: "abc123", + location: "Conference Room A", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + eventId: { + type: "string", + description: "The ID of the event to update.", + }, + calendarId: { + type: "string", + description: + "Optional calendar ID. If omitted, uses the primary calendar.", + }, + title: { + type: "string", + description: "New event title.", + }, + description: { + type: "string", + description: "New event description.", + }, + location: { + type: "string", + description: "New event location.", + }, + startTime: { + type: "string", + description: "New start time in ISO datetime format.", + }, + endTime: { + type: "string", + description: "New end time in ISO datetime format.", + }, + guests: { + type: "array", + items: { type: "string" }, + description: + "New guest list (replaces existing guests). Array of email addresses.", + }, + }, + required: ["eventId"], + additionalProperties: false, + }, + handler: async function ({ + eventId, + calendarId, + title, + description, + location, + startTime, + endTime, + guests, + }) { + try { + this.super.handlerProps.log(`Using the gcal-update-event tool.`); + + const updates = {}; + if (title !== undefined) updates.title = title; + if (description !== undefined) updates.description = description; + if (location !== undefined) updates.location = location; + if (startTime !== undefined) updates.startTime = startTime; + if (endTime !== undefined) updates.endTime = endTime; + if (guests !== undefined) updates.guests = guests; + + if (this.super.requestToolApproval) { + const updatedFields = Object.keys(updates).join(", "); + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { + eventId, + updates, + }, + description: `Update calendar event ${eventId} - changing: ${updatedFields}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Updating event ${eventId}...` + ); + + const result = await googleCalendarLib.updateEvent( + eventId, + calendarId, + updates + ); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to update event - ${result.error}` + ); + return `Error updating event: ${result.error}`; + } + + const event = result.data.event; + this.super.introspect( + `${this.caller}: Updated event "${event.title}"` + ); + + let timeInfo; + if (event.isAllDayEvent) { + timeInfo = `All-day event on ${new Date(event.startDate).toLocaleDateString()}`; + } else { + timeInfo = `${new Date(event.startTime).toLocaleString()} - ${new Date(event.endTime).toLocaleString()}`; + } + + const updatedFields = Object.keys(updates).join(", "); + + return ( + `Event updated successfully!\n\n` + + `Updated fields: ${updatedFields}\n\n` + + `Title: ${event.title}\n` + + `${timeInfo}\n` + + `Location: ${event.location || "(none)"}\n` + + `Event ID: ${event.eventId}` + ); + } catch (e) { + this.super.handlerProps.log( + `gcal-update-event error: ${e.message}` + ); + this.super.introspect(`Error: ${e.message}`); + return `Error updating event: ${e.message}`; + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/index.js b/server/utils/agents/aibitat/plugins/google-calendar/index.js new file mode 100644 index 000000000..3aa04cf8f --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/index.js @@ -0,0 +1,40 @@ +const { GCalListCalendars } = require("./calendars/gcal-list-calendars.js"); +const { GCalGetCalendar } = require("./calendars/gcal-get-calendar.js"); +const { GCalGetEvent } = require("./events/gcal-get-event.js"); +const { GCalGetEventsForDay } = require("./events/gcal-get-events-for-day.js"); +const { GCalGetEvents } = require("./events/gcal-get-events.js"); +const { + GCalGetUpcomingEvents, +} = require("./events/gcal-get-upcoming-events.js"); +const { GCalQuickAdd } = require("./events/gcal-quick-add.js"); +const { GCalCreateEvent } = require("./events/gcal-create-event.js"); +const { GCalUpdateEvent } = require("./events/gcal-update-event.js"); +const { GCalSetMyStatus } = require("./events/gcal-set-my-status.js"); + +const googleCalendarAgent = { + name: "google-calendar-agent", + startupConfig: { + params: {}, + }, + plugin: [ + // Calendars (read-only) + GCalListCalendars, + GCalGetCalendar, + + // Events - Read (read-only) + GCalGetEvent, + GCalGetEventsForDay, + GCalGetEvents, + GCalGetUpcomingEvents, + + // Events - Write (modifying) + GCalQuickAdd, + GCalCreateEvent, + GCalUpdateEvent, + GCalSetMyStatus, + ], +}; + +module.exports = { + googleCalendarAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/google-calendar/lib.js b/server/utils/agents/aibitat/plugins/google-calendar/lib.js new file mode 100644 index 000000000..181af47a5 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/google-calendar/lib.js @@ -0,0 +1,288 @@ +const { SystemSettings } = require("../../../../../models/systemSettings"); +const { safeJsonParse } = require("../../../../http"); + +/** + * Google Calendar Bridge Library + * Handles communication with the AnythingLLM Google Calendar Google Apps Script deployment. + */ +class GoogleCalendarBridge { + #deploymentId = null; + #apiKey = null; + #isInitialized = false; + + #log(text, ...args) { + console.log(`\x1b[35m[GoogleCalendarBridge]\x1b[0m ${text}`, ...args); + } + + /** + * Resets the bridge state, forcing re-initialization on next use. + * Call this when configuration changes (e.g., deployment ID updated). + */ + reset() { + this.#deploymentId = null; + this.#apiKey = null; + this.#isInitialized = false; + } + + /** + * Gets the current Google Calendar agent configuration from system settings. + * @returns {Promise<{deploymentId?: string, apiKey?: string}>} + */ + static async getConfig() { + const configJson = await SystemSettings.getValueOrFallback( + { label: "google_calendar_agent_config" }, + "{}" + ); + return safeJsonParse(configJson, {}); + } + + /** + * Updates the Google Calendar agent configuration in system settings. + * @param {Object} updates - Fields to update + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async updateConfig(updates) { + try { + await SystemSettings.updateSettings({ + google_calendar_agent_config: JSON.stringify(updates), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Initializes the Google Calendar bridge by fetching configuration from system settings. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async initialize() { + if (this.#isInitialized) return { success: true }; + + try { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) { + return { + success: false, + error: + "Google Calendar integration is not available in multi-user mode for security reasons.", + }; + } + + const config = await GoogleCalendarBridge.getConfig(); + if (!config.deploymentId || !config.apiKey) { + return { + success: false, + error: + "Google Calendar integration is not configured. Please set the Deployment ID and API Key in the agent settings.", + }; + } + + this.#deploymentId = config.deploymentId; + this.#apiKey = config.apiKey; + this.#isInitialized = true; + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Checks if the Google Calendar bridge is properly configured and available. + * @returns {Promise} + */ + async isAvailable() { + const result = await this.initialize(); + return result.success; + } + + /** + * Checks if Google Calendar tools are available (not in multi-user mode and has configuration). + * @returns {Promise} + */ + static async isToolAvailable() { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) return false; + + const config = await GoogleCalendarBridge.getConfig(); + return !!(config.deploymentId && config.apiKey); + } + + get maskedDeploymentId() { + if (!this.#deploymentId) return "(not configured)"; + return ( + this.#deploymentId.substring(0, 5) + + "..." + + this.#deploymentId.substring(this.#deploymentId.length - 5) + ); + } + + /** + * Gets the base URL for the Google Calendar Google Apps Script deployment. + * @returns {string} + */ + #getBaseUrl() { + this.#log(`Getting base URL for deployment ID ${this.maskedDeploymentId}`); + return `https://script.google.com/macros/s/${this.#deploymentId}/exec`; + } + + /** + * Makes a request to the Google Calendar Google Apps Script API. + * @param {string} action - The action to perform + * @param {object} params - Additional parameters for the action + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async request(action, params = {}) { + const initResult = await this.initialize(); + if (!initResult.success) { + return { success: false, error: initResult.error }; + } + + try { + const response = await fetch(this.#getBaseUrl(), { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-AnythingLLM-UA": "AnythingLLM-GoogleCalendar-Agent/1.0", + }, + body: JSON.stringify({ + key: this.#apiKey, + action, + ...params, + }), + }); + + if (!response.ok) { + return { + success: false, + error: `Google Calendar API request failed with status ${response.status}`, + }; + } + + const result = await response.json(); + + if (result.status === "error") { + return { success: false, error: result.error }; + } + + return { success: true, data: result.data }; + } catch (error) { + return { + success: false, + error: `Google Calendar API request failed: ${error.message}`, + }; + } + } + + /** + * List all calendars the user owns or is subscribed to. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async listCalendars() { + return this.request("list_calendars"); + } + + /** + * Get details of a specific calendar by ID. + * @param {string} calendarId - The calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getCalendar(calendarId) { + return this.request("get_calendar", { calendarId }); + } + + /** + * Get a single event by ID. + * @param {string} eventId - The event ID + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getEvent(eventId, calendarId) { + return this.request("get_event", { eventId, calendarId }); + } + + /** + * Get all events for a specific day. + * @param {string} date - ISO date string + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getEventsForDay(date, calendarId) { + return this.request("get_events_for_day", { date, calendarId }); + } + + /** + * Get events within a date range, optionally filtered by search query. + * @param {string} startDate - ISO datetime string + * @param {string} endDate - ISO datetime string + * @param {string} calendarId - Optional calendar ID + * @param {string} query - Optional search query + * @param {number} limit - Max results (default 25, max 100) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getEvents(startDate, endDate, calendarId, query, limit = 25) { + return this.request("get_events", { + startDate, + endDate, + calendarId, + query, + limit, + }); + } + + /** + * Create event from natural language description. + * @param {string} description - Natural language description + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async quickAdd(description, calendarId) { + return this.request("quick_add", { description, calendarId }); + } + + /** + * Create a single or recurring event (timed or all-day). + * @param {object} eventData - Event creation data + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createEvent(eventData) { + return this.request("create_event", eventData); + } + + /** + * Update an existing event. + * @param {string} eventId - The event ID + * @param {string} calendarId - Optional calendar ID + * @param {object} updates - Fields to update + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async updateEvent(eventId, calendarId, updates) { + return this.request("update_event", { + eventId, + calendarId, + ...updates, + }); + } + + /** + * Set your RSVP status for an event. + * @param {string} eventId - The event ID + * @param {string} status - RSVP status: "YES", "NO", "MAYBE", or "INVITED" + * @param {string} calendarId - Optional calendar ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async setMyStatus(eventId, status, calendarId) { + return this.request("set_my_status", { eventId, status, calendarId }); + } + + /** + * Get API version and available actions. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async version() { + return this.request("version"); + } +} + +module.exports = new GoogleCalendarBridge(); +module.exports.GoogleCalendarBridge = GoogleCalendarBridge; diff --git a/server/utils/agents/aibitat/plugins/http-socket.js b/server/utils/agents/aibitat/plugins/http-socket.js index af443c581..2ef479bff 100644 --- a/server/utils/agents/aibitat/plugins/http-socket.js +++ b/server/utils/agents/aibitat/plugins/http-socket.js @@ -1,6 +1,7 @@ const chalk = require("chalk"); const { Telemetry } = require("../../../../models/telemetry"); const { v4: uuidv4 } = require("uuid"); +const { skillIsAutoApproved } = require("../../../helpers/agents"); const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval /** @@ -118,7 +119,13 @@ const httpSocket = { payload = {}, description = null, }) { - // Check whitelist first + if (skillIsAutoApproved({ skillName })) { + return { + approved: true, + message: "Skill is auto-approved.", + }; + } + const { AgentSkillWhitelist, } = require("../../../../models/agentSkillWhitelist"); diff --git a/server/utils/agents/aibitat/plugins/index.js b/server/utils/agents/aibitat/plugins/index.js index 22c0c0df2..c81fcc78e 100644 --- a/server/utils/agents/aibitat/plugins/index.js +++ b/server/utils/agents/aibitat/plugins/index.js @@ -8,6 +8,9 @@ const { rechart } = require("./rechart.js"); const { sqlAgent } = require("./sql-agent/index.js"); const { filesystemAgent } = require("./filesystem/index.js"); const { createFilesAgent } = require("./create-files/index.js"); +const { gmailAgent } = require("./gmail/index.js"); +const { outlookAgent } = require("./outlook/index.js"); +const { googleCalendarAgent } = require("./google-calendar/index.js"); module.exports = { webScraping, @@ -20,6 +23,9 @@ module.exports = { sqlAgent, filesystemAgent, createFilesAgent, + gmailAgent, + outlookAgent, + googleCalendarAgent, // Plugin name aliases so they can be pulled by slug as well. [webScraping.name]: webScraping, @@ -32,4 +38,7 @@ module.exports = { [sqlAgent.name]: sqlAgent, [filesystemAgent.name]: filesystemAgent, [createFilesAgent.name]: createFilesAgent, + [gmailAgent.name]: gmailAgent, + [outlookAgent.name]: outlookAgent, + [googleCalendarAgent.name]: googleCalendarAgent, }; diff --git a/server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js b/server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js new file mode 100644 index 000000000..2013f4963 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/account/outlook-get-mailbox-stats.js @@ -0,0 +1,78 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookGetMailboxStats = { + name: "outlook-get-mailbox-stats", + plugin: function () { + return { + name: "outlook-get-mailbox-stats", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get Outlook mailbox statistics including folder counts and user profile information. " + + "Returns the total and unread counts for inbox, drafts, sent items, and deleted items.", + examples: [ + { + prompt: "How many unread emails do I have?", + call: JSON.stringify({}), + }, + { + prompt: "Show me my mailbox statistics", + call: JSON.stringify({}), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async function () { + try { + this.super.handlerProps.log( + `Using the outlook-get-mailbox-stats tool.` + ); + this.super.introspect( + `${this.caller}: Getting Outlook mailbox statistics...` + ); + + const result = await outlookLib.getMailboxStats(); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get mailbox stats - ${result.error}` + ); + return `Error getting mailbox statistics: ${result.error}`; + } + + const { email, displayName, folders } = result.data; + this.super.introspect( + `${this.caller}: Successfully retrieved mailbox statistics` + ); + + let folderStats = ""; + if (folders.inbox) + folderStats += `\nInbox: ${folders.inbox.total} total, ${folders.inbox.unread} unread`; + if (folders.drafts) + folderStats += `\nDrafts: ${folders.drafts.total} total`; + if (folders.sentitems) + folderStats += `\nSent Items: ${folders.sentitems.total} total`; + if (folders.deleteditems) + folderStats += `\nDeleted Items: ${folders.deleteditems.total} total`; + + return ( + `Outlook Mailbox Statistics:\n` + + `\nAccount: ${displayName} (${email})\n` + + `\nFolder Statistics:${folderStats}` + ); + } catch (e) { + return handleSkillError(this, "outlook-get-mailbox-stats", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js new file mode 100644 index 000000000..b07af2900 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-create-draft.js @@ -0,0 +1,232 @@ +const outlookLib = require("../lib.js"); +const { prepareAttachmentsWithValidation, handleSkillError } = outlookLib; + +module.exports.OutlookCreateDraft = { + name: "outlook-create-draft", + plugin: function () { + return { + name: "outlook-create-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Create a new draft email in Outlook. The draft will be saved but not sent. " + + "Can also create a draft reply to an existing message by providing replyToMessageId. " + + "You can optionally include CC, BCC recipients, HTML body content, and file attachments.", + examples: [ + { + prompt: + "Create a draft email to john@example.com about the meeting", + call: JSON.stringify({ + to: "john@example.com", + subject: "Meeting Tomorrow", + body: "Hi John,\n\nJust wanted to confirm our meeting tomorrow at 2pm.\n\nBest regards", + }), + }, + { + prompt: "Create a draft reply to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks for the update. I'll review and get back to you.", + }), + }, + { + prompt: "Create a draft reply-all to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks everyone for your input.", + replyAll: true, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + replyToMessageId: { + type: "string", + description: + "Message ID to reply to. If provided, creates a draft reply instead of a new draft. " + + "When replying, 'to' and 'subject' are optional (inherited from original).", + }, + replyAll: { + type: "boolean", + description: + "When replying, whether to reply to all recipients. Defaults to false.", + default: false, + }, + to: { + type: "string", + description: + "Recipient email address(es). Required for new drafts, optional for replies.", + }, + subject: { + type: "string", + description: + "Email subject line. Required for new drafts, optional for replies.", + }, + body: { + type: "string", + description: "Email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + isHtml: { + type: "boolean", + description: + "Whether the body is HTML content. Defaults to false.", + default: false, + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the draft.", + }, + }, + required: ["body"], + additionalProperties: false, + }, + handler: async function ({ + replyToMessageId, + replyAll = false, + to, + subject, + body, + cc, + bcc, + isHtml, + attachments, + }) { + try { + this.super.handlerProps.log( + `Using the outlook-create-draft tool.` + ); + + const isReply = !!replyToMessageId; + + if (!isReply && (!to || !subject)) { + return "Error: 'to' and 'subject' are required for new drafts. For draft replies, provide 'replyToMessageId'."; + } + + if (!body) { + return "Error: 'body' is required."; + } + + const attachmentResult = await prepareAttachmentsWithValidation( + this, + attachments + ); + if (!attachmentResult.success) { + return `Error with attachment: ${attachmentResult.error}`; + } + const { + attachments: preparedAttachments, + summaries: attachmentSummaries, + } = attachmentResult; + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const description = isReply + ? `Create draft ${replyAll ? "reply-all" : "reply"} to message ${replyToMessageId}${attachmentNote}` + : `Create Outlook draft to "${to}" with subject "${subject}"${attachmentNote}`; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: isReply + ? { replyToMessageId, replyAll } + : { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + let result; + if (isReply) { + this.super.introspect( + `${this.caller}: Creating draft ${replyAll ? "reply-all" : "reply"} to message...` + ); + result = await outlookLib.createDraftReply( + replyToMessageId, + body, + replyAll + ); + } else { + this.super.introspect( + `${this.caller}: Creating Outlook draft to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = { isHtml }; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + result = await outlookLib.createDraft( + to, + subject, + body, + options + ); + } + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to create draft - ${result.error}` + ); + return `Error creating draft: ${result.error}`; + } + + const draft = result.data; + this.super.introspect( + `${this.caller}: Successfully created draft (ID: ${draft.draftId})` + ); + + if (isReply) { + return ( + `Successfully created draft ${replyAll ? "reply-all" : "reply"}:\n` + + `Draft ID: ${draft.draftId}\n` + + `Subject: ${draft.subject}\n` + + `Type: ${replyAll ? "Reply All" : "Reply"}\n` + + `\nThe draft reply has been saved and can be edited or sent later.` + ); + } + + return ( + `Successfully created Outlook draft:\n` + + `Draft ID: ${draft.draftId}\n` + + `To: ${to}\n` + + `Subject: ${subject}\n` + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe draft has been saved and can be edited or sent later.` + ); + } catch (e) { + return handleSkillError(this, "outlook-create-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js new file mode 100644 index 000000000..d8c4ee928 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-delete-draft.js @@ -0,0 +1,86 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookDeleteDraft = { + name: "outlook-delete-draft", + plugin: function () { + return { + name: "outlook-delete-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Delete a draft email from Outlook. " + + "This permanently removes the draft and cannot be undone.", + examples: [ + { + prompt: "Delete the draft with ID AAMkAGI2...", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The ID of the draft to delete.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log( + `Using the outlook-delete-draft tool.` + ); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Delete draft ${draftId}? This cannot be undone.`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Deleting draft ${draftId}...` + ); + + const result = await outlookLib.deleteDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to delete draft - ${result.error}` + ); + return `Error deleting draft: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully deleted draft` + ); + + return `Successfully deleted draft (ID: ${draftId}).`; + } catch (e) { + return handleSkillError(this, "outlook-delete-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js new file mode 100644 index 000000000..b9049e95f --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-list-drafts.js @@ -0,0 +1,80 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookListDrafts = { + name: "outlook-list-drafts", + plugin: function () { + return { + name: "outlook-list-drafts", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "List all draft emails in Outlook. " + + "Returns a summary of each draft including ID, subject, recipients, and last modified date.", + examples: [ + { + prompt: "Show me my draft emails", + call: JSON.stringify({ limit: 25 }), + }, + { + prompt: "List drafts", + call: JSON.stringify({ limit: 10 }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of drafts to return (1-50). Defaults to 25.", + default: 25, + }, + }, + additionalProperties: false, + }, + handler: async function ({ limit = 25 }) { + try { + this.super.handlerProps.log( + `Using the outlook-list-drafts tool.` + ); + this.super.introspect( + `${this.caller}: Listing Outlook drafts...` + ); + + const result = await outlookLib.listDrafts(limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to list drafts - ${result.error}` + ); + return `Error listing drafts: ${result.error}`; + } + + const { drafts, count } = result.data; + this.super.introspect(`${this.caller}: Found ${count} drafts`); + + if (count === 0) { + return "No drafts found."; + } + + const summary = drafts + .map( + (d, i) => + `${i + 1}. "${d.subject || "(No Subject)"}" to ${d.to || "(No Recipients)"}\n ID: ${d.id}\n Last Modified: ${new Date(d.lastModified).toLocaleString()}\n Preview: ${d.preview?.substring(0, 100) || "(No preview)"}...` + ) + .join("\n\n"); + + return `Found ${count} drafts:\n\n${summary}\n\nUse the draft ID with outlook-get-draft to see full content, outlook-update-draft to modify, or outlook-send-draft to send.`; + } catch (e) { + return handleSkillError(this, "outlook-list-drafts", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js new file mode 100644 index 000000000..54fa67d4a --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-send-draft.js @@ -0,0 +1,82 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookSendDraft = { + name: "outlook-send-draft", + plugin: function () { + return { + name: "outlook-send-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an existing draft email from Outlook. " + + "This action sends the email immediately and cannot be undone.", + examples: [ + { + prompt: "Send the draft with ID AAMkAGI2...", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The ID of the draft to send.", + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId }) { + try { + this.super.handlerProps.log(`Using the outlook-send-draft tool.`); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId }, + description: `Send draft ${draftId}? This will send the email immediately.`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Sending draft ${draftId}...` + ); + + const result = await outlookLib.sendDraft(draftId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send draft - ${result.error}` + ); + return `Error sending draft: ${result.error}`; + } + + this.super.introspect(`${this.caller}: Successfully sent draft`); + + return `Successfully sent draft (ID: ${draftId}). The email has been delivered.`; + } catch (e) { + return handleSkillError(this, "outlook-send-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js new file mode 100644 index 000000000..872339185 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/drafts/outlook-update-draft.js @@ -0,0 +1,132 @@ +const outlookLib = require("../lib.js"); +const { handleSkillError } = outlookLib; + +module.exports.OutlookUpdateDraft = { + name: "outlook-update-draft", + plugin: function () { + return { + name: "outlook-update-draft", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Update an existing draft email in Outlook. " + + "You can modify the recipients, subject, or body of the draft.", + examples: [ + { + prompt: "Update the draft subject", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + subject: "Updated Meeting - Tomorrow at 3pm", + }), + }, + { + prompt: "Change the draft body and add CC", + call: JSON.stringify({ + draftId: "AAMkAGI2...", + body: "Updated content here.", + cc: "manager@example.com", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + draftId: { + type: "string", + description: "The ID of the draft to update.", + }, + to: { + type: "string", + description: "New recipient email address(es). Optional.", + }, + subject: { + type: "string", + description: "New email subject. Optional.", + }, + body: { + type: "string", + description: "New email body content. Optional.", + }, + cc: { + type: "string", + description: "New CC recipient email address(es). Optional.", + }, + isHtml: { + type: "boolean", + description: + "Whether the body is HTML content. Defaults to false.", + default: false, + }, + }, + required: ["draftId"], + additionalProperties: false, + }, + handler: async function ({ draftId, to, subject, body, cc, isHtml }) { + try { + this.super.handlerProps.log( + `Using the outlook-update-draft tool.` + ); + + if (!draftId) { + return "Error: 'draftId' is required."; + } + + if (!to && !subject && !body && !cc) { + return "Error: At least one field to update (to, subject, body, cc) is required."; + } + + if (this.super.requestToolApproval) { + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: { draftId, hasChanges: { to, subject, body, cc } }, + description: `Update draft ${draftId}`, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + this.super.introspect( + `${this.caller}: Updating draft ${draftId}...` + ); + + const updates = { isHtml }; + if (to) updates.to = to; + if (subject) updates.subject = subject; + if (body) updates.body = body; + if (cc) updates.cc = cc; + + const result = await outlookLib.updateDraft(draftId, updates); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to update draft - ${result.error}` + ); + return `Error updating draft: ${result.error}`; + } + + this.super.introspect( + `${this.caller}: Successfully updated draft` + ); + + return ( + `Successfully updated draft:\n` + + `Draft ID: ${result.data.draftId}\n` + + `Subject: ${result.data.subject}\n` + + `\nThe draft has been updated.` + ); + } catch (e) { + return handleSkillError(this, "outlook-update-draft", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/index.js b/server/utils/agents/aibitat/plugins/outlook/index.js new file mode 100644 index 000000000..8ecdd905e --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/index.js @@ -0,0 +1,45 @@ +const { OutlookGetInbox } = require("./search/outlook-get-inbox.js"); +const { OutlookSearch } = require("./search/outlook-search.js"); +const { OutlookReadThread } = require("./search/outlook-read-thread.js"); + +const { OutlookCreateDraft } = require("./drafts/outlook-create-draft.js"); +const { OutlookUpdateDraft } = require("./drafts/outlook-update-draft.js"); +const { OutlookListDrafts } = require("./drafts/outlook-list-drafts.js"); +const { OutlookDeleteDraft } = require("./drafts/outlook-delete-draft.js"); +const { OutlookSendDraft } = require("./drafts/outlook-send-draft.js"); + +const { OutlookSendEmail } = require("./send/outlook-send-email.js"); + +const { + OutlookGetMailboxStats, +} = require("./account/outlook-get-mailbox-stats.js"); + +const outlookAgent = { + name: "outlook-agent", + startupConfig: { + params: {}, + }, + plugin: [ + // Inbox & Search (read-only) + OutlookGetInbox, + OutlookSearch, + OutlookReadThread, + + // Drafts (create-draft also supports replies via replyToMessageId) + OutlookCreateDraft, + OutlookUpdateDraft, + OutlookListDrafts, + OutlookDeleteDraft, + OutlookSendDraft, + + // Send (send-email also supports replies via replyToMessageId) + OutlookSendEmail, + + // Account (read-only) + OutlookGetMailboxStats, + ], +}; + +module.exports = { + outlookAgent, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/lib.js b/server/utils/agents/aibitat/plugins/outlook/lib.js new file mode 100644 index 000000000..93a510c23 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/lib.js @@ -0,0 +1,1413 @@ +const fs = require("fs"); +const path = require("path"); +const os = require("os"); +const mime = require("mime"); +const { SystemSettings } = require("../../../../../models/systemSettings"); +const { CollectorApi } = require("../../../../collectorApi"); +const { humanFileSize } = require("../../../../helpers"); +const { safeJsonParse } = require("../../../../http"); + +const MAX_TOTAL_ATTACHMENT_SIZE = 25 * 1024 * 1024; // 25MB limit for Outlook + +/** + * Parses a comma-separated email string into Graph API recipient format. + * @param {string} emailString - Comma-separated email addresses + * @returns {Array<{emailAddress: {address: string}}>} + */ +function parseEmailRecipients(emailString) { + if (!emailString) return []; + return emailString.split(",").map((email) => ({ + emailAddress: { address: email.trim() }, + })); +} + +/** + * Validates organization auth type configuration. + * @param {Object} config - The Outlook configuration object + * @returns {{valid: boolean, error?: string}} + */ +function validateOrganizationAuth(config) { + const authType = config.authType || AUTH_TYPES.common; + if (authType === AUTH_TYPES.organization && !config.tenantId) { + return { + valid: false, + error: "Tenant ID is required for organization-only authentication.", + }; + } + return { valid: true }; +} + +/** + * Maps a Microsoft Graph message object to a simplified format. + * @param {Object} msg - The Graph API message object + * @param {Object} options - Mapping options + * @param {boolean} options.includeBody - Include full body content + * @param {boolean} options.includeAttachments - Include attachment details + * @returns {Object} Simplified message object + */ +function mapGraphMessage(msg, options = {}) { + const base = { + id: msg.id, + conversationId: msg.conversationId, + subject: msg.subject, + from: msg.from?.emailAddress?.address || "Unknown", + fromName: msg.from?.emailAddress?.name || "", + to: msg.toRecipients?.map((r) => r.emailAddress?.address).join(", ") || "", + cc: msg.ccRecipients?.map((r) => r.emailAddress?.address).join(", ") || "", + isRead: msg.isRead, + hasAttachments: msg.hasAttachments, + }; + + if (msg.receivedDateTime) { + base.receivedDateTime = msg.receivedDateTime; + } + if (msg.bodyPreview !== undefined) { + base.preview = msg.bodyPreview; + } + + if (options.includeBody && msg.body) { + base.date = msg.receivedDateTime; + base.body = msg.body?.content || ""; + base.bodyType = msg.body?.contentType || "text"; + } + + if (options.includeAttachments && msg.attachments) { + base.attachments = (msg.attachments || []).map((att) => ({ + id: att.id, + name: att.name, + contentType: att.contentType, + size: att.size, + contentBytes: att.contentBytes, + })); + } + + return base; +} + +/** + * Formats an array of messages into a human-readable summary string. + * @param {Array} messages - Array of simplified message objects + * @returns {string} Formatted summary + */ +function formatMessageSummary(messages) { + return messages + .map( + (m, i) => + `${i + 1}. [${m.isRead ? "READ" : "UNREAD"}] "${m.subject}" from ${m.fromName || m.from} (${new Date(m.receivedDateTime).toLocaleString()})${m.hasAttachments ? " 📎" : ""}\n ID: ${m.id}\n Conversation ID: ${m.conversationId}` + ) + .join("\n\n"); +} + +/** + * Handles errors in Outlook skill handlers with consistent logging and messaging. + * @param {Object} context - The handler context (this) from the aibitat function + * @param {string} skillName - The name of the skill (e.g., "outlook-get-inbox") + * @param {Error} error - The error object + * @returns {string} User-friendly error message + */ +function handleSkillError(context, skillName, error) { + context.super.handlerProps.log(`${skillName} error: ${error.message}`); + context.super.introspect(`Error: ${error.message}`); + return `Error in ${skillName}: ${error.message}`; +} + +/** + * Normalizes a token expiry value to a number. + * @param {number|string|null|undefined} expiry - The token expiry value + * @returns {number|null} The normalized expiry as a number, or null if invalid + */ +function normalizeTokenExpiry(expiry) { + if (expiry === null || expiry === undefined) return null; + return typeof expiry === "number" ? expiry : parseInt(expiry, 10); +} + +/** + * Prepares and validates attachments for email sending/drafting. + * @param {Object} context - The handler context with introspect and caller + * @param {Array} attachmentPaths - Array of absolute file paths + * @param {Object} options - Options for attachment handling + * @param {boolean} options.requireApprovalPerFile - Request approval for each file + * @param {string} options.recipientInfo - Recipient info for approval message + * @returns {Promise<{success: boolean, attachments?: Array, summaries?: Array, totalSize?: number, error?: string}>} + */ +async function prepareAttachmentsWithValidation( + context, + attachmentPaths, + options = {} +) { + if (!Array.isArray(attachmentPaths) || attachmentPaths.length === 0) { + return { success: true, attachments: [], summaries: [], totalSize: 0 }; + } + + const preparedAttachments = []; + const attachmentSummaries = []; + let totalAttachmentSize = 0; + + context.super.introspect( + `${context.caller}: Validating ${attachmentPaths.length} attachment(s)...` + ); + + for (const filePath of attachmentPaths) { + const result = prepareAttachment(filePath); + if (!result.success) { + context.super.introspect( + `${context.caller}: Attachment validation failed - ${result.error}` + ); + return { success: false, error: result.error }; + } + + totalAttachmentSize += result.fileInfo.size; + if (totalAttachmentSize > MAX_TOTAL_ATTACHMENT_SIZE) { + const totalFormatted = humanFileSize(totalAttachmentSize, true); + context.super.introspect( + `${context.caller}: Total attachment size (${totalFormatted}) exceeds 25MB limit` + ); + return { + success: false, + error: `Total attachment size (${totalFormatted}) exceeds the 25MB limit.`, + }; + } + + if (options.requireApprovalPerFile && context.super.requestToolApproval) { + const approval = await context.super.requestToolApproval({ + skillName: context.name, + payload: { + fileName: result.fileInfo.name, + fileSize: result.fileInfo.sizeFormatted, + filePath: result.fileInfo.path, + }, + description: + `Attach file "${result.fileInfo.name}" (${result.fileInfo.sizeFormatted}) to email? ` + + `This file will be sent to ${options.recipientInfo || "recipients"}.`, + }); + + if (!approval.approved) { + context.super.introspect( + `${context.caller}: User rejected attaching "${result.fileInfo.name}"` + ); + return { + success: false, + error: `Attachment rejected by user: ${result.fileInfo.name}. ${approval.message || ""}`, + }; + } + } + + preparedAttachments.push(result.attachment); + attachmentSummaries.push( + `${result.fileInfo.name} (${result.fileInfo.sizeFormatted})` + ); + context.super.introspect( + `${context.caller}: Prepared attachment "${result.fileInfo.name}"` + ); + } + + return { + success: true, + attachments: preparedAttachments, + summaries: attachmentSummaries, + totalSize: totalAttachmentSize, + }; +} + +// ============================================================================ +// Attachment Functions +// ============================================================================ + +/** + * Validates and prepares a file attachment for email. + * @param {string} filePath - Absolute path to the file + * @returns {{success: boolean, attachment?: object, error?: string, fileInfo?: object}} + */ +function prepareAttachment(filePath) { + if (process.env.ANYTHING_LLM_RUNTIME === "docker") { + return { + success: false, + error: "File attachments are not supported in Docker environments.", + }; + } + + if (!path.isAbsolute(filePath)) { + return { success: false, error: `Path must be absolute: ${filePath}` }; + } + + if (!fs.existsSync(filePath)) { + return { success: false, error: `File does not exist: ${filePath}` }; + } + + const stats = fs.statSync(filePath); + if (!stats.isFile()) { + return { success: false, error: `Path is not a file: ${filePath}` }; + } + + if (stats.size === 0) { + return { success: false, error: `File is empty: ${filePath}` }; + } + + try { + const fileBuffer = fs.readFileSync(filePath); + const base64Data = fileBuffer.toString("base64"); + const fileName = path.basename(filePath); + const contentType = mime.getType(filePath) || "application/octet-stream"; + + return { + success: true, + attachment: { + "@odata.type": "#microsoft.graph.fileAttachment", + name: fileName, + contentType, + contentBytes: base64Data, + }, + fileInfo: { + path: filePath, + name: fileName, + size: stats.size, + sizeFormatted: humanFileSize(stats.size, true), + contentType, + }, + }; + } catch (e) { + return { success: false, error: `Failed to read file: ${e.message}` }; + } +} + +/** + * Parse an attachment using the CollectorApi for secure content extraction. + * @param {Object} attachment - The attachment object with name, contentType, size, contentBytes (base64) + * @returns {Promise<{success: boolean, content: string|null, error: string|null}>} + */ +async function parseAttachment(attachment) { + const tempDir = os.tmpdir(); + const safeFilename = attachment.name.replace(/[^a-zA-Z0-9._-]/g, "_"); + const tempFilePath = path.join( + tempDir, + `outlook-attachment-${Date.now()}-${safeFilename}` + ); + + try { + const buffer = Buffer.from(attachment.contentBytes, "base64"); + fs.writeFileSync(tempFilePath, buffer); + + const collector = new CollectorApi(); + const result = await collector.parseDocument(safeFilename, { + absolutePath: tempFilePath, + }); + + if (fs.existsSync(tempFilePath)) { + fs.unlinkSync(tempFilePath); + } + + if (!result.success) { + return { + success: false, + content: null, + error: result.reason || "Failed to parse attachment", + }; + } + + const textContent = result.documents + ?.map((doc) => doc.pageContent || doc.content || "") + .filter(Boolean) + .join("\n\n"); + + return { + success: true, + content: textContent || "(No text content extracted)", + error: null, + }; + } catch (e) { + if (fs.existsSync(tempFilePath)) { + try { + fs.unlinkSync(tempFilePath); + } catch {} + } + return { success: false, content: null, error: e.message }; + } +} + +/** + * MIME types that can be parsed by the collector to extract text content. + * These are a subset of ACCEPTED_MIMES from collector/utils/constants.js + * that are suitable for attachment parsing (excludes audio/video and images). + * Images are excluded because they're typically signature images in emails. + */ +const PARSEABLE_ATTACHMENT_MIMES = [ + "text/plain", + "text/html", + "text/csv", + "application/json", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", // .docx + "application/vnd.openxmlformats-officedocument.presentationml.presentation", // .pptx + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", // .xlsx + "application/vnd.oasis.opendocument.text", // .odt + "application/vnd.oasis.opendocument.presentation", // .odp + "application/pdf", + "application/epub+zip", +]; + +/** + * Checks if an attachment's MIME type can be parsed for text extraction. + * @param {string} contentType - The MIME type of the attachment + * @returns {boolean} + */ +function isParseableMimeType(contentType) { + if (!contentType) return false; + const baseMime = contentType.split(";")[0].trim().toLowerCase(); + return PARSEABLE_ATTACHMENT_MIMES.includes(baseMime); +} + +/** + * Collect attachments from messages and optionally parse them with user approval. + * Only attachments with parseable MIME types will be offered for parsing. + * If two attachments have the same name, only the first one will be kept (handling fwd emails) + * @param {Object} context - The handler context (this) from the aibitat function + * @param {Array} messages - Array of message objects + * @returns {Promise<{allAttachments: Array, parsedContent: string}>} + */ +async function handleAttachments(context, messages) { + const allAttachments = []; + const uniqueAttachments = new Set(); + messages.forEach((msg, msgIndex) => { + if (msg.attachments?.length > 0) { + msg.attachments.forEach((att) => { + if (uniqueAttachments.has(att.name)) return; + uniqueAttachments.add(att.name); + allAttachments.push({ + ...att, + messageIndex: msgIndex + 1, + messageId: msg.id, + }); + }); + } + }); + + const parseableAttachments = allAttachments.filter((att) => + isParseableMimeType(att.contentType) + ); + + let parsedContent = ""; + const citations = []; + if (parseableAttachments.length > 0 && context.super.requestToolApproval) { + const attachmentNames = parseableAttachments + .map((a) => `${a.name} (${a.contentType})`) + .join(", "); + + const approval = await context.super.requestToolApproval({ + skillName: context.name, + payload: { attachments: attachmentNames }, + description: `Download and parse ${parseableAttachments.length} attachment(s) to extract text content? (${attachmentNames})`, + }); + + if (approval.approved) { + context.super.introspect( + `${context.caller}: Downloading and parsing ${parseableAttachments.length} attachment(s)...` + ); + + const parsedResults = []; + for (const attachment of parseableAttachments) { + if (!attachment.contentBytes) { + context.super.introspect( + `${context.caller}: Skipping "${attachment.name}" - no content available` + ); + continue; + } + context.super.introspect( + `${context.caller}: Parsing "${attachment.name}"...` + ); + const parseResult = await parseAttachment(attachment); + if (!parseResult.success) { + context.super.introspect( + `${context.caller}: Failed to parse "${attachment.name}": ${parseResult.error}` + ); + continue; + } + + citations.push({ + id: `outlook-attachment-${attachment.messageId}-${attachment.name}`, + title: attachment.name, + text: parseResult.content, + chunkSource: "outlook-attachment://" + attachment.name, + score: null, + }); + parsedResults.push({ + name: attachment.name, + messageIndex: attachment.messageIndex, + ...parseResult, + }); + } + + if (parsedResults.length > 0) { + parsedContent = + "\n\n--- Parsed Attachment Content ---\n" + + parsedResults + .map( + (r) => `\n[Message ${r.messageIndex}: ${r.name}]\n${r.content}` + ) + .join("\n"); + } + + context.super.introspect( + `${context.caller}: Finished parsing attachments (${parsedResults.length}/${parseableAttachments.length} successful)` + ); + } else { + context.super.introspect( + `${context.caller}: User declined to parse attachments` + ); + } + } + + citations.forEach((c) => context.super.addCitation?.(c)); + return { allAttachments, parsedContent }; +} + +/** + * Microsoft Graph API OAuth2 Configuration + * Uses Authorization Code Flow with PKCE + */ +const MICROSOFT_AUTH_URL = "https://login.microsoftonline.com"; +const GRAPH_API_URL = "https://graph.microsoft.com/v1.0"; +const SCOPES = [ + "offline_access", + "Mail.Read", + "Mail.ReadWrite", + "Mail.Send", + "User.Read", +].join(" "); + +/** + * Authentication types for Microsoft OAuth2. + * - "organization": Use tenant ID endpoint (work/school accounts from a specific tenant only) + * - "common": Use /common endpoint (both personal and work/school accounts) + * - "consumers": Use /consumers endpoint (personal Microsoft accounts only) + */ +const AUTH_TYPES = { + organization: "organization", + common: "common", + consumers: "consumers", +}; + +/** + * Gets the appropriate OAuth2 authority endpoint based on auth type. + * @param {string} authType - The authentication type + * @param {string} tenantId - The tenant ID (used only for "organization" type) + * @returns {string} The authority path segment + */ +function getAuthority(authType, tenantId) { + switch (authType) { + case AUTH_TYPES.consumers: + return "consumers"; + case AUTH_TYPES.organization: + return tenantId || "common"; + case AUTH_TYPES.common: + default: + return "common"; + } +} + +/** + * Outlook Bridge Library + * Handles OAuth2 authentication and Microsoft Graph API communication for Outlook mail. + */ +class OutlookBridge { + #accessToken = null; + #isInitialized = false; + + #log(text, ...args) { + console.log(`\x1b[35m[OutlookBridge]\x1b[0m ${text}`, ...args); + } + + /** + * Decodes a JWT token and logs relevant info. + * @param {string} token - The JWT token to decode + * @param {string} context - Context label for logging (e.g., "NEW token", "Token") + * @returns {Object|null} The decoded payload or null if decoding fails + */ + #decodeAndLogToken(token, context = "Token") { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = JSON.parse(Buffer.from(parts[1], "base64").toString()); + this.#log( + `${context} for: ${payload.upn || payload.email || payload.unique_name || "unknown"}` + ); + return payload; + } catch { + this.#log(`Could not decode ${context.toLowerCase()}`); + return null; + } + } + + /** + * Resets the bridge state, forcing re-initialization on next use. + */ + reset() { + this.#accessToken = null; + this.#isInitialized = false; + } + + /** + * Gets the current Outlook agent configuration from system settings. + * @returns {Promise<{clientId?: string, tenantId?: string, clientSecret?: string, authType?: string, accessToken?: string, refreshToken?: string, tokenExpiry?: number}>} + */ + static async getConfig() { + const configJson = await SystemSettings.getValueOrFallback( + { label: "outlook_agent_config" }, + "{}" + ); + return safeJsonParse(configJson, {}); + } + + /** + * Updates the Outlook agent configuration in system settings. + * @param {Object} updates - Fields to update + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async updateConfig(updates) { + try { + await SystemSettings.updateSettings({ + outlook_agent_config: JSON.stringify(updates), + }); + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Generates the OAuth2 authorization URL for the user to authenticate. + * @param {string} redirectUri - The callback URL for OAuth + * @returns {Promise<{success: boolean, url?: string, error?: string}>} + */ + async getAuthUrl(redirectUri) { + const config = await OutlookBridge.getConfig(); + + if (!config.clientId) { + return { + success: false, + error: "Outlook configuration incomplete. Please set Client ID.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + const authType = config.authType || AUTH_TYPES.common; + const authority = getAuthority(authType, config.tenantId); + const params = new URLSearchParams({ + client_id: config.clientId, + response_type: "code", + redirect_uri: redirectUri, + response_mode: "query", + scope: SCOPES, + prompt: "consent", + }); + + const url = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/authorize?${params.toString()}`; + this.#log(`Auth URL using authType: ${authType}, authority: ${authority}`); + return { success: true, url }; + } + + /** + * Exchanges the authorization code for access and refresh tokens. + * @param {string} code - The authorization code from OAuth callback + * @param {string} redirectUri - The callback URL used in the initial auth request + * @returns {Promise<{success: boolean, error?: string}>} + */ + async exchangeCodeForToken(code, redirectUri) { + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret) { + return { + success: false, + error: "Outlook configuration incomplete.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + try { + const authType = config.authType || AUTH_TYPES.common; + const authority = getAuthority(authType, config.tenantId); + const tokenUrl = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/token`; + this.#log( + `Token exchange using authType: ${authType}, authority: ${authority}` + ); + + const params = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + code, + redirect_uri: redirectUri, + grant_type: "authorization_code", + scope: SCOPES, + }); + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + const data = await response.json(); + + if (!response.ok) { + this.#log("Token exchange failed:", data); + return { + success: false, + error: + data.error_description || data.error || "Token exchange failed", + }; + } + + const expiresAt = Date.now() + (data.expires_in - 60) * 1000; + this.#decodeAndLogToken(data.access_token, "NEW token received"); + await OutlookBridge.updateConfig({ + ...config, + accessToken: data.access_token, + refreshToken: data.refresh_token, + tokenExpiry: expiresAt, + }); + + this.#accessToken = data.access_token; + this.#isInitialized = false; // Force re-initialization + + this.#log("Successfully obtained tokens"); + return { success: true }; + } catch (error) { + this.#log("Token exchange error:", error.message); + return { success: false, error: error.message }; + } + } + + /** + * Refreshes the access token using the refresh token. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async #refreshAccessToken() { + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret || !config.refreshToken) { + return { + success: false, + error: "Cannot refresh token. Missing configuration or refresh token.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + try { + const authType = config.authType || AUTH_TYPES.common; + const authority = getAuthority(authType, config.tenantId); + const tokenUrl = `${MICROSOFT_AUTH_URL}/${authority}/oauth2/v2.0/token`; + this.#log( + `Token refresh using authType: ${authType}, authority: ${authority}` + ); + + const params = new URLSearchParams({ + client_id: config.clientId, + client_secret: config.clientSecret, + refresh_token: config.refreshToken, + grant_type: "refresh_token", + scope: SCOPES, + }); + + const response = await fetch(tokenUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + body: params.toString(), + }); + + const data = await response.json(); + + if (!response.ok) { + this.#log("Token refresh failed:", data); + return { + success: false, + error: data.error_description || data.error || "Token refresh failed", + }; + } + + const expiresAt = Date.now() + (data.expires_in - 60) * 1000; + + await OutlookBridge.updateConfig({ + ...config, + accessToken: data.access_token, + refreshToken: data.refresh_token || config.refreshToken, + tokenExpiry: expiresAt, + }); + + this.#accessToken = data.access_token; + + this.#log("Successfully refreshed access token"); + return { success: true }; + } catch (error) { + this.#log("Token refresh error:", error.message); + return { success: false, error: error.message }; + } + } + + /** + * Ensures we have a valid access token, refreshing if necessary. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async #ensureValidToken() { + const config = await OutlookBridge.getConfig(); + + if (!config.accessToken || !config.tokenExpiry) { + this.#log("No access token or expiry found in config"); + return { + success: false, + error: "Outlook is not authenticated. Please complete the OAuth flow.", + }; + } + + const expiryTime = normalizeTokenExpiry(config.tokenExpiry); + + const now = Date.now(); + const timeUntilExpiry = expiryTime - now; + this.#log( + `Token check: expires in ${Math.round(timeUntilExpiry / 1000)}s (at ${new Date(expiryTime).toISOString()})` + ); + + const payload = this.#decodeAndLogToken(config.accessToken, "Token check"); + if (payload) { + this.#log(`Token aud: ${payload.aud}`); + this.#log(`Token scp: ${payload.scp}`); + } + + if (now >= expiryTime) { + this.#log("Access token expired, refreshing..."); + return this.#refreshAccessToken(); + } + + this.#accessToken = config.accessToken; + return { success: true }; + } + + /** + * Initializes the Outlook bridge by fetching configuration from system settings. + * @returns {Promise<{success: boolean, error?: string}>} + */ + async initialize() { + if (this.#isInitialized) return { success: true }; + + try { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) { + return { + success: false, + error: + "Outlook integration is not available in multi-user mode for security reasons.", + }; + } + + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret) { + return { + success: false, + error: + "Outlook integration is not configured. Please set Client ID and Client Secret in the agent settings.", + }; + } + + const orgAuth = validateOrganizationAuth(config); + if (!orgAuth.valid) { + return { success: false, error: orgAuth.error }; + } + + this.#log( + `Initializing with authType: ${config.authType || AUTH_TYPES.common}` + ); + + if (!config.accessToken) { + return { + success: false, + error: + "Outlook is not authenticated. Please complete the OAuth authorization flow.", + }; + } + + const tokenResult = await this.#ensureValidToken(); + if (!tokenResult.success) { + return tokenResult; + } + + this.#isInitialized = true; + return { success: true }; + } catch (error) { + return { success: false, error: error.message }; + } + } + + /** + * Checks if the Outlook bridge is properly configured and available. + * @returns {Promise} + */ + async isAvailable() { + const result = await this.initialize(); + return result.success; + } + + /** + * Checks if Outlook tools are available (not in multi-user mode and has configuration). + * @returns {Promise} + */ + static async isToolAvailable() { + const isMultiUser = await SystemSettings.isMultiUserMode(); + if (isMultiUser) return false; + + const config = await OutlookBridge.getConfig(); + + if (!config.clientId || !config.clientSecret || !config.accessToken) { + return false; + } + + const orgAuth = validateOrganizationAuth(config); + return orgAuth.valid; + } + + /** + * Makes a request to the Microsoft Graph API. + * @param {string} endpoint - The API endpoint (relative to /v1.0) + * @param {object} options - Fetch options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async request(endpoint, options = {}) { + const initResult = await this.initialize(); + if (!initResult.success) { + this.#log(`Initialize failed: ${initResult.error}`); + return { success: false, error: initResult.error }; + } + + const tokenResult = await this.#ensureValidToken(); + if (!tokenResult.success) { + this.#log(`Token validation failed: ${tokenResult.error}`); + return { success: false, error: tokenResult.error }; + } + + try { + const url = endpoint.startsWith("http") + ? endpoint + : `${GRAPH_API_URL}${endpoint}`; + + this.#log(`Making request to: ${url}`); + + const response = await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${this.#accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorData = {}; + try { + errorData = JSON.parse(errorText); + } catch { + errorData = { raw: errorText }; + } + this.#log( + `API request failed: ${response.status} ${response.statusText}`, + `\n Endpoint: ${endpoint}`, + `\n Headers: ${JSON.stringify(Object.fromEntries(response.headers.entries()))}`, + `\n Error: ${JSON.stringify(errorData, null, 2)}` + ); + return { + success: false, + error: + errorData.error?.message || + `Request failed with status ${response.status}`, + }; + } + + // Handle responses with no content (204 No Content, 202 Accepted with empty body, etc.) + if (response.status === 204 || response.status === 202) { + return { success: true, data: {} }; + } + + const text = await response.text(); + if (!text || text.trim() === "") { + return { success: true, data: {} }; + } + + const data = JSON.parse(text); + return { success: true, data }; + } catch (error) { + return { + success: false, + error: `Outlook API request failed: ${error.message}`, + }; + } + } + + /** + * Search emails using OData filter syntax. + * Note: Microsoft Graph API does not support $skip with $search, so pagination + * is only available when no query is provided. + * @param {string} query - Search query (uses Microsoft Search syntax) + * @param {number} limit - Maximum results to return + * @param {number} skip - Number of results to skip for pagination (ignored when query is provided) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async search(query = "", limit = 10, skip = 0) { + let endpoint; + + if (query) { + // $skip is not supported with $search in Microsoft Graph API + // $orderby is also not supported with $search - results are ordered by relevance + endpoint = `/me/messages?$top=${limit}&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId&$search="${encodeURIComponent(query)}"`; + } else { + endpoint = `/me/messages?$top=${limit}&$skip=${skip}&$orderby=receivedDateTime desc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId`; + } + + const result = await this.request(endpoint); + if (!result.success) return result; + + const messages = result.data.value || []; + return { + success: true, + data: { + messages: messages.map((msg) => mapGraphMessage(msg)), + resultCount: messages.length, + hasMore: !!result.data["@odata.nextLink"], + }, + }; + } + + /** + * Get inbox messages (only from the Inbox folder, not archived/other folders). + * @param {number} limit - Maximum results to return + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getInbox(limit = 25) { + const endpoint = `/me/mailFolders/inbox/messages?$top=${limit}&$orderby=receivedDateTime desc&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,bodyPreview,conversationId`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + const messages = result.data.value || []; + return { + success: true, + data: { + messages: messages.map((msg) => mapGraphMessage(msg)), + resultCount: messages.length, + hasMore: !!result.data["@odata.nextLink"], + }, + }; + } + + /** + * Read a full conversation thread by conversation ID. + * Note: We avoid combining $filter with $orderby due to Microsoft Graph API + * "InefficientFilter" errors. Instead, we fetch without orderby and sort client-side. + * @param {string} conversationId - The conversation ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async readThread(conversationId) { + const endpoint = `/me/messages?$filter=conversationId eq '${conversationId}'&$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,attachments&$expand=attachments`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + let messages = result.data.value || []; + if (messages.length === 0) { + return { + success: false, + error: "No messages found in this conversation.", + }; + } + + // Sort by receivedDateTime ascending (oldest first) client-side + messages.sort( + (a, b) => new Date(a.receivedDateTime) - new Date(b.receivedDateTime) + ); + + return { + success: true, + data: { + conversationId, + subject: messages[0]?.subject || "No Subject", + messageCount: messages.length, + messages: messages.map((msg) => + mapGraphMessage(msg, { includeBody: true, includeAttachments: true }) + ), + }, + }; + } + + /** + * Read a single message by ID. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async readMessage(messageId) { + const endpoint = `/me/messages/${messageId}?$select=id,subject,from,toRecipients,ccRecipients,receivedDateTime,isRead,hasAttachments,body,conversationId&$expand=attachments`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + return { + success: true, + data: mapGraphMessage(result.data, { + includeBody: true, + includeAttachments: true, + }), + }; + } + + /** + * Create a new draft email. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options (cc, bcc, isHtml, attachments) + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraft(to, subject, body, options = {}) { + const message = { + subject, + body: { + contentType: options.isHtml ? "HTML" : "Text", + content: body, + }, + toRecipients: parseEmailRecipients(to), + }; + + if (options.cc) { + message.ccRecipients = parseEmailRecipients(options.cc); + } + + if (options.bcc) { + message.bccRecipients = parseEmailRecipients(options.bcc); + } + + const result = await this.request("/me/messages", { + method: "POST", + body: JSON.stringify(message), + }); + + if (!result.success) return result; + + const draftId = result.data.id; + + if (options.attachments && options.attachments.length > 0) { + for (const attachment of options.attachments) { + const attachResult = await this.request( + `/me/messages/${draftId}/attachments`, + { + method: "POST", + body: JSON.stringify(attachment), + } + ); + if (!attachResult.success) { + this.#log(`Failed to add attachment: ${attachResult.error}`); + } + } + } + + return { + success: true, + data: { + draftId: result.data.id, + subject: result.data.subject, + to, + webLink: result.data.webLink, + }, + }; + } + + /** + * Create a draft reply to an existing message. + * @param {string} messageId - The message ID to reply to + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async createDraftReply(messageId, body, replyAll = false, _options = {}) { + const endpoint = replyAll + ? `/me/messages/${messageId}/createReplyAll` + : `/me/messages/${messageId}/createReply`; + + const result = await this.request(endpoint, { + method: "POST", + body: JSON.stringify({ + comment: body, + }), + }); + + if (!result.success) return result; + + return { + success: true, + data: { + draftId: result.data.id, + subject: result.data.subject, + webLink: result.data.webLink, + }, + }; + } + + /** + * Get a specific draft by ID. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getDraft(draftId) { + return this.readMessage(draftId); + } + + /** + * List all drafts. + * @param {number} limit - Maximum drafts to return + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async listDrafts(limit = 25) { + const endpoint = `/me/mailFolders/drafts/messages?$top=${limit}&$orderby=lastModifiedDateTime desc&$select=id,subject,toRecipients,lastModifiedDateTime,bodyPreview`; + + const result = await this.request(endpoint); + if (!result.success) return result; + + const drafts = result.data.value || []; + return { + success: true, + data: { + drafts: drafts.map((draft) => ({ + id: draft.id, + subject: draft.subject, + to: + draft.toRecipients + ?.map((r) => r.emailAddress?.address) + .join(", ") || "", + lastModified: draft.lastModifiedDateTime, + preview: draft.bodyPreview, + })), + count: drafts.length, + }, + }; + } + + /** + * Update an existing draft. + * @param {string} draftId - The draft ID + * @param {object} updates - Fields to update + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async updateDraft(draftId, updates) { + const message = {}; + + if (updates.subject) message.subject = updates.subject; + if (updates.body) { + message.body = { + contentType: updates.isHtml ? "HTML" : "Text", + content: updates.body, + }; + } + if (updates.to) { + message.toRecipients = parseEmailRecipients(updates.to); + } + if (updates.cc) { + message.ccRecipients = parseEmailRecipients(updates.cc); + } + + const result = await this.request(`/me/messages/${draftId}`, { + method: "PATCH", + body: JSON.stringify(message), + }); + + if (!result.success) return result; + + return { + success: true, + data: { + draftId: result.data.id, + subject: result.data.subject, + }, + }; + } + + /** + * Delete a draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async deleteDraft(draftId) { + return this.request(`/me/messages/${draftId}`, { + method: "DELETE", + }); + } + + /** + * Send an existing draft. + * @param {string} draftId - The draft ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async sendDraft(draftId) { + return this.request(`/me/messages/${draftId}/send`, { + method: "POST", + }); + } + + /** + * Send an email immediately. + * @param {string} to - Recipient email + * @param {string} subject - Email subject + * @param {string} body - Email body + * @param {object} options - Additional options + * @returns {Promise<{success: boolean, error?: string}>} + */ + async sendEmail(to, subject, body, options = {}) { + const message = { + subject, + body: { + contentType: options.isHtml ? "HTML" : "Text", + content: body, + }, + toRecipients: parseEmailRecipients(to), + }; + + if (options.cc) { + message.ccRecipients = parseEmailRecipients(options.cc); + } + + if (options.bcc) { + message.bccRecipients = parseEmailRecipients(options.bcc); + } + + if (options.attachments && options.attachments.length > 0) { + message.attachments = options.attachments; + } + + return this.request("/me/sendMail", { + method: "POST", + body: JSON.stringify({ + message, + saveToSentItems: true, + }), + }); + } + + /** + * Reply to a message immediately. + * @param {string} messageId - The message ID to reply to + * @param {string} body - Reply body + * @param {boolean} replyAll - Whether to reply all + * @returns {Promise<{success: boolean, error?: string}>} + */ + async replyToMessage(messageId, body, replyAll = false) { + const endpoint = replyAll + ? `/me/messages/${messageId}/replyAll` + : `/me/messages/${messageId}/reply`; + + return this.request(endpoint, { + method: "POST", + body: JSON.stringify({ + comment: body, + }), + }); + } + + /** + * Mark a message as read. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async markRead(messageId) { + return this.request(`/me/messages/${messageId}`, { + method: "PATCH", + body: JSON.stringify({ isRead: true }), + }); + } + + /** + * Mark a message as unread. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async markUnread(messageId) { + return this.request(`/me/messages/${messageId}`, { + method: "PATCH", + body: JSON.stringify({ isRead: false }), + }); + } + + /** + * Move a message to trash. + * @param {string} messageId - The message ID + * @returns {Promise<{success: boolean, error?: string}>} + */ + async moveToTrash(messageId) { + return this.request(`/me/messages/${messageId}/move`, { + method: "POST", + body: JSON.stringify({ + destinationId: "deleteditems", + }), + }); + } + + /** + * Get mailbox folder statistics. + * @returns {Promise<{success: boolean, data?: object, error?: string}>} + */ + async getMailboxStats() { + const folders = ["inbox", "drafts", "sentitems", "deleteditems"]; + const stats = {}; + + for (const folder of folders) { + const result = await this.request( + `/me/mailFolders/${folder}?$select=displayName,totalItemCount,unreadItemCount` + ); + if (result.success) { + stats[folder] = { + name: result.data.displayName, + total: result.data.totalItemCount, + unread: result.data.unreadItemCount, + }; + } + } + + const profileResult = await this.request("/me?$select=displayName,mail"); + + return { + success: true, + data: { + email: profileResult.data?.mail || "Unknown", + displayName: profileResult.data?.displayName || "Unknown", + folders: stats, + }, + }; + } +} + +module.exports = new OutlookBridge(); +module.exports.OutlookBridge = OutlookBridge; +module.exports.prepareAttachment = prepareAttachment; +module.exports.parseAttachment = parseAttachment; +module.exports.handleAttachments = handleAttachments; +module.exports.isParseableMimeType = isParseableMimeType; +module.exports.PARSEABLE_ATTACHMENT_MIMES = PARSEABLE_ATTACHMENT_MIMES; +module.exports.MAX_TOTAL_ATTACHMENT_SIZE = MAX_TOTAL_ATTACHMENT_SIZE; +module.exports.AUTH_TYPES = AUTH_TYPES; +module.exports.formatMessageSummary = formatMessageSummary; +module.exports.mapGraphMessage = mapGraphMessage; +module.exports.parseEmailRecipients = parseEmailRecipients; +module.exports.validateOrganizationAuth = validateOrganizationAuth; +module.exports.prepareAttachmentsWithValidation = + prepareAttachmentsWithValidation; +module.exports.handleSkillError = handleSkillError; +module.exports.normalizeTokenExpiry = normalizeTokenExpiry; diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js new file mode 100644 index 000000000..1220e180a --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-get-inbox.js @@ -0,0 +1,75 @@ +const outlookLib = require("../lib.js"); +const { formatMessageSummary, handleSkillError } = outlookLib; + +module.exports.OutlookGetInbox = { + name: "outlook-get-inbox", + plugin: function () { + return { + name: "outlook-get-inbox", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Get recent emails from the Outlook inbox. " + + "Returns a list of recent messages with subject, sender, date, and read status. " + + "Use this to quickly see what's in the inbox.", + examples: [ + { + prompt: "Show me my recent emails", + call: JSON.stringify({ limit: 10 }), + }, + { + prompt: "What's in my inbox?", + call: JSON.stringify({ limit: 25 }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + limit: { + type: "number", + description: + "Maximum number of messages to return (1-50). Defaults to 25.", + default: 25, + }, + }, + additionalProperties: false, + }, + handler: async function ({ limit = 25 }) { + try { + this.super.handlerProps.log(`Using the outlook-get-inbox tool.`); + this.super.introspect( + `${this.caller}: Fetching Outlook inbox...` + ); + + const result = await outlookLib.getInbox(limit); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to get inbox - ${result.error}` + ); + return `Error getting Outlook inbox: ${result.error}`; + } + + const { messages, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} messages in inbox` + ); + + if (resultCount === 0) { + return "No messages found in inbox."; + } + + const summary = formatMessageSummary(messages); + return `Found ${resultCount} messages in inbox:\n\n${summary}\n\nAlways include the conversation ID in the response. Use the conversation ID with outlook-read-thread to read the full conversation.`; + } catch (e) { + return handleSkillError(this, "outlook-get-inbox", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js new file mode 100644 index 000000000..14e92dc8d --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-read-thread.js @@ -0,0 +1,131 @@ +const outlookLib = require("../lib.js"); +const { handleAttachments, handleSkillError } = outlookLib; + +module.exports.OutlookReadThread = { + name: "outlook-read-thread", + plugin: function () { + return { + name: "outlook-read-thread", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Read a full email conversation thread by its conversation ID. " + + "Returns all messages in the thread including sender, recipients, subject, body, date, and attachment information. " + + "Use this after searching to read the full conversation.", + examples: [ + { + prompt: "Read the email thread with conversation ID AAQkAGI2...", + call: JSON.stringify({ + conversationId: "AAQkAGI2...", + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + conversationId: { + type: "string", + description: "The Outlook conversation ID to read.", + }, + }, + required: ["conversationId"], + additionalProperties: false, + }, + handler: async function ({ conversationId }) { + try { + this.super.handlerProps.log( + `Using the outlook-read-thread tool.` + ); + + if (!conversationId) { + return "Error: conversationId is required."; + } + + this.super.introspect( + `${this.caller}: Reading Outlook conversation...` + ); + + const result = await outlookLib.readThread(conversationId); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to read thread - ${result.error}` + ); + return `Error reading Outlook thread: ${result.error}`; + } + + const thread = result.data; + + const { allAttachments, parsedContent: parsedAttachmentContent } = + await handleAttachments(this, thread.messages); + + const messagesFormatted = thread.messages + .map((msg, i) => { + let attachmentInfo = ""; + if (msg.attachments?.length > 0) { + attachmentInfo = `\n Attachments: ${msg.attachments.map((a) => `${a.name} (${a.contentType}, ${(a.size / 1024).toFixed(1)}KB)`).join(", ")}`; + } + + const bodyContent = + msg.bodyType === "html" + ? msg.body + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim() + : msg.body; + + return ( + `--- Message ${i + 1} ---\n` + + `From: ${msg.fromName} <${msg.from}>\n` + + `To: ${msg.to}\n` + + (msg.cc ? `CC: ${msg.cc}\n` : "") + + `Date: ${new Date(msg.date).toLocaleString()}\n` + + `Subject: ${msg.subject}\n` + + `Status: ${msg.isRead ? "READ" : "UNREAD"}\n` + + `\n${bodyContent}` + + attachmentInfo + ); + }) + .join("\n\n"); + + this.super.introspect( + `${this.caller}: Successfully read thread with ${thread.messageCount} messages` + ); + + // Report citation for the thread (without attachments) + this.super.addCitation?.({ + id: `outlook-thread-${thread.conversationId}`, + title: thread.subject, + text: `Subject: "${thread.subject}"\n\n${messagesFormatted}`, + chunkSource: `outlook-thread://${this._generatePermalink(thread.conversationId)}`, + score: null, + }); + + return ( + `Thread: "${thread.subject}"\n` + + `Conversation ID: ${thread.conversationId}\n` + + `Messages: ${thread.messageCount}\n` + + `Total Attachments: ${allAttachments.length}\n\n` + + messagesFormatted + + parsedAttachmentContent + ); + } catch (e) { + return handleSkillError(this, "outlook-read-thread", e); + } + }, + _generatePermalink: function (conversationId) { + if (!conversationId) return null; + let encodedId = encodeURIComponent(conversationId); + // For outlook, this needs to be specifically encoded + // as the webpage does not respect it like traditional URL encoding + encodedId = encodedId.replace(/-/g, "%2F"); + return `https://outlook.live.com/mail/inbox/id/${encodedId}`; + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js b/server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js new file mode 100644 index 000000000..e9698142c --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/search/outlook-search.js @@ -0,0 +1,101 @@ +const outlookLib = require("../lib.js"); +const { formatMessageSummary, handleSkillError } = outlookLib; + +module.exports.OutlookSearch = { + name: "outlook-search", + plugin: function () { + return { + name: "outlook-search", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Search emails in Outlook using Microsoft Search syntax. " + + "Supports searching by keywords, sender, subject, and more. " + + "Common search terms: 'from:email', 'subject:word', 'hasAttachments:true'. " + + "Returns message summaries with ID, subject, date, and read status.", + examples: [ + { + prompt: "Search for emails about the project", + call: JSON.stringify({ + query: "project update", + limit: 10, + }), + }, + { + prompt: "Find emails from john@example.com", + call: JSON.stringify({ + query: "from:john@example.com", + limit: 20, + }), + }, + { + prompt: "Search for emails with attachments about invoices", + call: JSON.stringify({ + query: "hasAttachments:true invoice", + limit: 15, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + query: { + type: "string", + description: + "Search query. Use Microsoft Search syntax like 'from:email', 'subject:keyword', etc.", + }, + limit: { + type: "number", + description: + "Maximum number of results to return (1-50). Defaults to 10.", + default: 10, + }, + skip: { + type: "number", + description: + "Number of results to skip for pagination. Defaults to 0.", + default: 0, + }, + }, + required: ["query"], + additionalProperties: false, + }, + handler: async function ({ query, limit = 10, skip = 0 }) { + try { + this.super.handlerProps.log(`Using the outlook-search tool.`); + this.super.introspect( + `${this.caller}: Searching Outlook with query "${query}"` + ); + + const result = await outlookLib.search(query, limit, skip); + + if (!result.success) { + this.super.introspect( + `${this.caller}: Outlook search failed - ${result.error}` + ); + return `Error searching Outlook: ${result.error}`; + } + + const { messages, resultCount } = result.data; + this.super.introspect( + `${this.caller}: Found ${resultCount} messages matching query` + ); + + if (resultCount === 0) { + return `No emails found matching query "${query}".`; + } + + const summary = formatMessageSummary(messages); + return `Found ${resultCount} messages:\n\n${summary}\n\nAlways include the conversation ID in the response. Use the conversation ID with outlook-read-thread to read the full conversation.`; + } catch (e) { + return handleSkillError(this, "outlook-search", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js b/server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js new file mode 100644 index 000000000..e93c66841 --- /dev/null +++ b/server/utils/agents/aibitat/plugins/outlook/send/outlook-send-email.js @@ -0,0 +1,222 @@ +const outlookLib = require("../lib.js"); +const { prepareAttachmentsWithValidation, handleSkillError } = outlookLib; + +module.exports.OutlookSendEmail = { + name: "outlook-send-email", + plugin: function () { + return { + name: "outlook-send-email", + setup(aibitat) { + aibitat.function({ + super: aibitat, + name: this.name, + description: + "Send an email immediately through Outlook. " + + "This action sends the email right away and cannot be undone. " + + "Can also reply to an existing message by providing replyToMessageId. " + + "For composing emails that need review before sending, use outlook-create-draft instead.", + examples: [ + { + prompt: "Send an email to john@example.com about the project", + call: JSON.stringify({ + to: "john@example.com", + subject: "Project Update", + body: "Hi John,\n\nHere's the latest update on the project.\n\nBest regards", + }), + }, + { + prompt: "Reply to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks for the update. I'll review and get back to you.", + }), + }, + { + prompt: "Reply all to message AAMkAGI2...", + call: JSON.stringify({ + replyToMessageId: "AAMkAGI2...", + body: "Thanks everyone for your input.", + replyAll: true, + }), + }, + ], + parameters: { + $schema: "http://json-schema.org/draft-07/schema#", + type: "object", + properties: { + replyToMessageId: { + type: "string", + description: + "Message ID to reply to. If provided, sends a reply instead of a new email. " + + "When replying, 'to' and 'subject' are optional (inherited from original).", + }, + replyAll: { + type: "boolean", + description: + "When replying, whether to reply to all recipients. Defaults to false.", + default: false, + }, + to: { + type: "string", + description: + "Recipient email address(es). Required for new emails, optional for replies.", + }, + subject: { + type: "string", + description: + "Email subject line. Required for new emails, optional for replies.", + }, + body: { + type: "string", + description: "Email body content.", + }, + cc: { + type: "string", + description: "CC recipient email address(es). Optional.", + }, + bcc: { + type: "string", + description: "BCC recipient email address(es). Optional.", + }, + isHtml: { + type: "boolean", + description: + "Whether the body is HTML content. Defaults to false.", + default: false, + }, + attachments: { + type: "array", + items: { type: "string" }, + description: + "Array of absolute file paths to attach to the email.", + }, + }, + required: ["body"], + additionalProperties: false, + }, + handler: async function ({ + replyToMessageId, + replyAll = false, + to, + subject, + body, + cc, + bcc, + isHtml, + attachments, + }) { + try { + this.super.handlerProps.log(`Using the outlook-send-email tool.`); + + const isReply = !!replyToMessageId; + + if (!isReply && (!to || !subject)) { + return "Error: 'to' and 'subject' are required for new emails. For replies, provide 'replyToMessageId'."; + } + + if (!body) { + return "Error: 'body' is required."; + } + + const attachmentResult = await prepareAttachmentsWithValidation( + this, + attachments, + { requireApprovalPerFile: true, recipientInfo: to } + ); + if (!attachmentResult.success) { + return `Error with attachment: ${attachmentResult.error}`; + } + const { + attachments: preparedAttachments, + summaries: attachmentSummaries, + } = attachmentResult; + + if (this.super.requestToolApproval) { + const attachmentNote = + preparedAttachments.length > 0 + ? ` with ${preparedAttachments.length} attachment(s): ${attachmentSummaries.join(", ")}` + : ""; + const description = isReply + ? `Send ${replyAll ? "reply-all" : "reply"} to message ${replyToMessageId}${attachmentNote}? This will send immediately.` + : `Send email to "${to}" with subject "${subject}"${attachmentNote} - This will send immediately`; + const approval = await this.super.requestToolApproval({ + skillName: this.name, + payload: isReply + ? { replyToMessageId, replyAll } + : { + to, + subject, + attachmentCount: preparedAttachments.length, + }, + description, + }); + if (!approval.approved) { + this.super.introspect( + `${this.caller}: User rejected the ${this.name} request.` + ); + return approval.message; + } + } + + let result; + if (isReply) { + this.super.introspect( + `${this.caller}: Sending ${replyAll ? "reply-all" : "reply"} to message...` + ); + result = await outlookLib.replyToMessage( + replyToMessageId, + body, + replyAll + ); + } else { + this.super.introspect( + `${this.caller}: Sending email to ${to}${preparedAttachments.length > 0 ? ` with ${preparedAttachments.length} attachment(s)` : ""}` + ); + + const options = { isHtml }; + if (cc) options.cc = cc; + if (bcc) options.bcc = bcc; + if (preparedAttachments.length > 0) { + options.attachments = preparedAttachments; + } + + result = await outlookLib.sendEmail(to, subject, body, options); + } + + if (!result.success) { + this.super.introspect( + `${this.caller}: Failed to send - ${result.error}` + ); + return `Error sending: ${result.error}`; + } + + if (isReply) { + this.super.introspect( + `${this.caller}: Successfully sent ${replyAll ? "reply-all" : "reply"}` + ); + return `Successfully sent ${replyAll ? "reply-all" : "reply"} to message (ID: ${replyToMessageId}). The reply has been delivered.`; + } + + this.super.introspect( + `${this.caller}: Successfully sent email to ${to}` + ); + + return ( + `Successfully sent email:\n` + + `To: ${to}\n` + + `Subject: ${subject}\n` + + (cc ? `CC: ${cc}\n` : "") + + (preparedAttachments.length > 0 + ? `Attachments: ${attachmentSummaries.join(", ")}\n` + : "") + + `\nThe email has been sent.` + ); + } catch (e) { + return handleSkillError(this, "outlook-send-email", e); + } + }, + }); + }, + }; + }, +}; diff --git a/server/utils/agents/aibitat/plugins/web-scraping.js b/server/utils/agents/aibitat/plugins/web-scraping.js index bc5f66cc4..ad35ce863 100644 --- a/server/utils/agents/aibitat/plugins/web-scraping.js +++ b/server/utils/agents/aibitat/plugins/web-scraping.js @@ -49,13 +49,14 @@ const webScraping = { if (url) return await this.scrape(url); return "There is nothing we can do. This function call returns no information."; } catch (error) { + const errorMessage = error?.message ?? JSON.stringify(error); this.super.handlerProps.log( - `Web Scraping Error: ${error.message}` + `Web Scraping Error: ${errorMessage}` ); this.super.introspect( - `${this.caller}: Web Scraping Error: ${error.message}` + `${this.caller}: Web Scraping Error: ${errorMessage}` ); - return `There was an error while calling the function. No data or response was found. Let the user know this was the error: ${error.message}`; + return `There was an error while calling the function. No data or response was found. Let the user know this was the error: ${errorMessage}`; } }, diff --git a/server/utils/agents/aibitat/plugins/websocket.js b/server/utils/agents/aibitat/plugins/websocket.js index b64407fd9..188aefb0f 100644 --- a/server/utils/agents/aibitat/plugins/websocket.js +++ b/server/utils/agents/aibitat/plugins/websocket.js @@ -2,6 +2,7 @@ const chalk = require("chalk"); const { Telemetry } = require("../../../../models/telemetry"); const { v4: uuidv4 } = require("uuid"); const { safeJsonParse } = require("../../../http"); +const { skillIsAutoApproved } = require("../../../helpers/agents"); const SOCKET_TIMEOUT_MS = 300 * 1_000; // 5 mins const TOOL_APPROVAL_TIMEOUT_MS = 120 * 1_000; // 2 mins for tool approval @@ -100,6 +101,13 @@ const websocket = { payload = {}, description = null, }) { + if (skillIsAutoApproved({ skillName })) { + return { + approved: true, + message: "Skill is auto-approved.", + }; + } + const { AgentSkillWhitelist, } = require("../../../../models/agentSkillWhitelist"); diff --git a/server/utils/agents/aibitat/providers/ai-provider.js b/server/utils/agents/aibitat/providers/ai-provider.js index 4d2347675..2a51bb16d 100644 --- a/server/utils/agents/aibitat/providers/ai-provider.js +++ b/server/utils/agents/aibitat/providers/ai-provider.js @@ -402,11 +402,13 @@ class Provider { configuration: { baseURL: process.env.LEMONADE_LLM_BASE_PATH, }, - apiKey: process.env.LEMONADE_LLM_API_KEY ?? null, + apiKey: process.env.LEMONADE_LLM_API_KEY || null, ...config, }); default: - throw new Error(`Unsupported provider ${provider} for this task.`); + throw new Error( + `Unsupported provider ${JSON.stringify(provider)} for this task.` + ); } } diff --git a/server/utils/agents/aibitat/providers/genericOpenAi.js b/server/utils/agents/aibitat/providers/genericOpenAi.js index a4cbdb162..64f7a932c 100644 --- a/server/utils/agents/aibitat/providers/genericOpenAi.js +++ b/server/utils/agents/aibitat/providers/genericOpenAi.js @@ -58,18 +58,20 @@ class GenericOpenAiProvider extends InheritMultiple([Provider, UnTooled]) { */ supportsNativeToolCalling() { if (this._supportsToolCalling !== null) return this._supportsToolCalling; - const supportsToolCalling = - this.supportsNativeToolCallingViaEnv("generic-openai"); - if (supportsToolCalling) + const genericOpenAi = new GenericOpenAiLLM(null, this.model); + const capabilities = genericOpenAi.getModelCapabilities(); + this._supportsToolCalling = capabilities.tools === true; + + if (this._supportsToolCalling) this.providerLog( - "Generic OpenAI supports native tool calling is ENABLED via ENV." + "Generic OpenAI supports native tool calling is ENABLED." ); else this.providerLog( - "Generic OpenAI supports native tool calling is DISABLED via ENV. Will use UnTooled instead." + "Generic OpenAI supports native tool calling is DISABLED. Will use UnTooled instead." ); - this._supportsToolCalling = supportsToolCalling; - return supportsToolCalling; + + return this._supportsToolCalling; } async #handleFunctionCallChat({ messages = [] }) { diff --git a/server/utils/agents/aibitat/providers/helpers/tooled.js b/server/utils/agents/aibitat/providers/helpers/tooled.js index fab68aad1..08b124ddf 100644 --- a/server/utils/agents/aibitat/providers/helpers/tooled.js +++ b/server/utils/agents/aibitat/providers/helpers/tooled.js @@ -227,13 +227,19 @@ async function tooledStream( for (const toolCall of choice.delta.tool_calls) { const idx = toolCall.index ?? 0; - if (toolCall.id) { + // Initialize tool call entry if it doesn't exist yet. + // Some providers (e.g. mlx-server) send id as null, so we generate one. + if (!toolCallsByIndex[idx]) { toolCallsByIndex[idx] = { - id: toolCall.id, + id: toolCall.id || `call_${v4()}`, name: toolCall.function?.name || "", arguments: toolCall.function?.arguments || "", }; - } else if (toolCallsByIndex[idx]) { + } else { + // Update existing entry with streamed data + if (toolCall.id && !toolCallsByIndex[idx].id.startsWith("call_")) { + toolCallsByIndex[idx].id = toolCall.id; + } if (toolCall.function?.name) { toolCallsByIndex[idx].name += toolCall.function.name; } diff --git a/server/utils/agents/aibitat/providers/lemonade.js b/server/utils/agents/aibitat/providers/lemonade.js index 0e6715d56..1c47a2ac0 100644 --- a/server/utils/agents/aibitat/providers/lemonade.js +++ b/server/utils/agents/aibitat/providers/lemonade.js @@ -27,7 +27,7 @@ class LemonadeProvider extends InheritMultiple([Provider, UnTooled]) { process.env.LEMONADE_LLM_BASE_PATH, "openai" ), - apiKey: process.env.LEMONADE_LLM_API_KEY ?? null, + apiKey: process.env.LEMONADE_LLM_API_KEY || null, maxRetries: 3, }); diff --git a/server/utils/agents/defaults.js b/server/utils/agents/defaults.js index c51bfc845..4d2cecf5f 100644 --- a/server/utils/agents/defaults.js +++ b/server/utils/agents/defaults.js @@ -13,6 +13,33 @@ const DEFAULT_SKILLS = [ AgentPlugins.webScraping.name, ]; +/** + * Configuration for agent skills that require availability checks and disabled sub-skill lists. + * Each entry maps a skill name to its availability checker and disabled skills list key. + */ +const SKILL_FILTER_CONFIG = { + "filesystem-agent": { + getAvailability: () => + require("./aibitat/plugins/filesystem/lib").isToolAvailable(), + disabledSettingKey: "disabled_filesystem_skills", + }, + "create-files-agent": { + getAvailability: () => + require("./aibitat/plugins/create-files/lib").isToolAvailable(), + disabledSettingKey: "disabled_create_files_skills", + }, + "gmail-agent": { + getAvailability: async () => + require("./aibitat/plugins/gmail/lib").GmailBridge.isToolAvailable(), + disabledSettingKey: "disabled_gmail_skills", + }, + "outlook-agent": { + getAvailability: async () => + require("./aibitat/plugins/outlook/lib").OutlookBridge.isToolAvailable(), + disabledSettingKey: "disabled_outlook_skills", + }, +}; + const USER_AGENT = { name: "USER", getDefinition: () => { @@ -98,43 +125,48 @@ async function agentSkillsFromSystemSettings() { ), [] ); - _setting.forEach((skillName) => { - if (!AgentPlugins.hasOwnProperty(skillName)) return; + + // Pre-load disabled sub-skills and availability for configured skills + const skillFilterState = {}; + for (const skillName of Object.keys(SKILL_FILTER_CONFIG)) { + if (!_setting.includes(skillName)) continue; + const config = SKILL_FILTER_CONFIG[skillName]; + skillFilterState[skillName] = { + available: await config.getAvailability(), + disabledSubSkills: safeJsonParse( + await SystemSettings.getValueOrFallback( + { label: config.disabledSettingKey }, + "[]" + ), + [] + ), + }; + } + + for (const skillName of _setting) { + if (!AgentPlugins.hasOwnProperty(skillName)) continue; // This is a plugin module with many sub-children plugins who // need to be named via `${parent}#${child}` naming convention if (Array.isArray(AgentPlugins[skillName].plugin)) { for (const subPlugin of AgentPlugins[skillName].plugin) { - /** - * If the filesystem tool is not available, or the sub-skill is explicitly disabled, skip it - * This is a docker specific skill so it cannot be used in other environments. - */ - if (skillName === "filesystem-agent") { - const filesystemTool = require("./aibitat/plugins/filesystem/lib"); - if (!filesystemTool.isToolAvailable()) continue; - if (_disabledFilesystemSkills.includes(subPlugin.name)) continue; - } - - /** - * If the create-files tool is not available, or the sub-skill is explicitly disabled, skip it - * This is a docker specific skill so it cannot be used in other environments. - */ - if (skillName === "create-files-agent") { - const createFilesTool = require("./aibitat/plugins/create-files/lib"); - if (!createFilesTool.isToolAvailable()) continue; - if (_disabledCreateFilesSkills.includes(subPlugin.name)) continue; + // Check if this skill has filter configuration + const filterState = skillFilterState[skillName]; + if (filterState) { + if (!filterState.available) continue; + if (filterState.disabledSubSkills.includes(subPlugin.name)) continue; } systemFunctions.push( `${AgentPlugins[skillName].name}#${subPlugin.name}` ); } - return; + continue; } // This is normal single-stage plugin systemFunctions.push(AgentPlugins[skillName].name); - }); + } return systemFunctions; } diff --git a/server/utils/chats/agents.js b/server/utils/chats/agents.js index 292043970..a4e8356b7 100644 --- a/server/utils/chats/agents.js +++ b/server/utils/chats/agents.js @@ -95,12 +95,8 @@ async function grepAgents({ writeResponseChunk(response, { id: uuid, type: "statusResponse", - textResponse: `${pluralize( - "Agent", - agentHandles.length - )} ${agentHandles.join( - ", " - )} invoked.\nSwapping over to agent chat. Type /exit to exit agent execution loop early.`, + textResponse: + "@agent: Swapping over to agent chat. Type /exit to exit agent execution loop early.", sources: [], close: true, error: null, diff --git a/server/utils/chats/apiChatHandler.js b/server/utils/chats/apiChatHandler.js index a0dc13bd9..2770c8770 100644 --- a/server/utils/chats/apiChatHandler.js +++ b/server/utils/chats/apiChatHandler.js @@ -17,7 +17,12 @@ const { Telemetry } = require("../../models/telemetry"); const { CollectorApi } = require("../collectorApi"); const fs = require("fs"); const path = require("path"); -const { hotdirPath, normalizePath, isWithin } = require("../files"); +const { + hotdirPath, + normalizePath, + isWithin, + sanitizeFileName, +} = require("../files"); /** * @typedef ResponseObject * @property {string} id - uuid of response @@ -72,8 +77,8 @@ async function processDocumentAttachments(attachments = []) { if (dataUriMatch) base64Data = dataUriMatch[1]; const buffer = Buffer.from(base64Data, "base64"); - const filename = normalizePath( - attachment.name || `attachment-${uuidv4()}` + const filename = sanitizeFileName( + normalizePath(attachment.name || `attachment-${uuidv4()}`) ); const filePath = normalizePath(path.join(hotdirPath, filename)); if (!isWithin(hotdirPath, filePath)) diff --git a/server/utils/files/index.js b/server/utils/files/index.js index 37fcd4620..740a0869d 100644 --- a/server/utils/files/index.js +++ b/server/utils/files/index.js @@ -284,6 +284,21 @@ function normalizePath(filepath = "") { return result; } +/** + * Strips characters that are illegal in Windows filenames, including Unicode + * quotation marks (U+201C, U+201D, etc.) that can get corrupted into ASCII + * double-quotes during charset conversion in the upload pipeline. + * @param {string} fileName - The filename to sanitize. + * @returns {string} - The sanitized filename. + */ +function sanitizeFileName(fileName) { + if (!fileName) return fileName; + return fileName.replace( + /[<>:"/\\|?*\u201C\u201D\u201E\u201F\u2018\u2019\u201A\u201B]/g, + "" + ); +} + // Check if the vector-cache folder is empty or not // useful for it the user is changing embedders as this will // break the previous cache. @@ -500,4 +515,5 @@ module.exports = { purgeEntireVectorCache, getDocumentsByFolder, hotdirPath, + sanitizeFileName, }; diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js index ee0de4b11..74c0704a8 100644 --- a/server/utils/files/multer.js +++ b/server/utils/files/multer.js @@ -2,7 +2,7 @@ const multer = require("multer"); const path = require("path"); const fs = require("fs"); const { v4 } = require("uuid"); -const { normalizePath } = require("."); +const { normalizePath, sanitizeFileName } = require("."); /** * Handle File uploads for auto-uploading. @@ -17,8 +17,8 @@ const fileUploadStorage = multer.diskStorage({ cb(null, uploadOutput); }, filename: function (_, file, cb) { - file.originalname = normalizePath( - Buffer.from(file.originalname, "latin1").toString("utf8") + file.originalname = sanitizeFileName( + normalizePath(Buffer.from(file.originalname, "latin1").toString("utf8")) ); cb(null, file.originalname); }, @@ -37,8 +37,8 @@ const fileAPIUploadStorage = multer.diskStorage({ cb(null, uploadOutput); }, filename: function (_, file, cb) { - file.originalname = normalizePath( - Buffer.from(file.originalname, "latin1").toString("utf8") + file.originalname = sanitizeFileName( + normalizePath(Buffer.from(file.originalname, "latin1").toString("utf8")) ); cb(null, file.originalname); }, @@ -55,8 +55,8 @@ const assetUploadStorage = multer.diskStorage({ return cb(null, uploadOutput); }, filename: function (_, file, cb) { - file.originalname = normalizePath( - Buffer.from(file.originalname, "latin1").toString("utf8") + file.originalname = sanitizeFileName( + normalizePath(Buffer.from(file.originalname, "latin1").toString("utf8")) ); cb(null, file.originalname); }, diff --git a/server/utils/helpers/agents.js b/server/utils/helpers/agents.js new file mode 100644 index 000000000..567b2c54f --- /dev/null +++ b/server/utils/helpers/agents.js @@ -0,0 +1,35 @@ +const chalk = require("chalk"); + +/** + * Checks if a skill is auto-approved by the ENV variable AGENT_AUTO_APPROVED_SKILLS. + * which is a comma-separated list of skill names. This property applies globally to all users + * so that all invocations of the skill are auto-approved without user interaction. + * @param {Object} options - The options object + * @param {string} options.skillName - The name of the skill + * @returns {boolean} True if the skill is auto-approved, false otherwise + */ +function skillIsAutoApproved({ skillName }) { + if ((!"AGENT_AUTO_APPROVED_SKILLS") in process.env) return false; + const autoApprovedSkills = String(process.env.AGENT_AUTO_APPROVED_SKILLS) + .split(",") + .map((skill) => skill.trim()) + .filter((skill) => !!skill); + + // If the list contains , then all skills are auto-approved + // This is a special case and overrides any other items in the list. + if (autoApprovedSkills.includes("")) return true; + + if (!autoApprovedSkills.length || !autoApprovedSkills.includes(skillName)) + return false; + + console.log( + chalk.green( + `Skill ${skillName} is auto-approved by the ENV variable AGENT_AUTO_APPROVED_SKILLS.` + ) + ); + return true; +} + +module.exports = { + skillIsAutoApproved, +}; diff --git a/server/utils/helpers/index.js b/server/utils/helpers/index.js index 09c2bcf5c..2c4b3d863 100644 --- a/server/utils/helpers/index.js +++ b/server/utils/helpers/index.js @@ -536,6 +536,41 @@ function toChunks(arr, size) { ); } +/** + * Report chunk-level embedding progress from any embedder. + * Works in both the child worker process (IPC via process.send) and the + * main server process (direct SSE emit via EmbeddingWorkerManager). + * + * Requires `global.__embeddingProgress` to be set by the caller with + * { workspaceSlug, filename, userId }. + * + * @param {number} chunksProcessed + * @param {number} totalChunks + */ +function reportEmbeddingProgress(chunksProcessed, totalChunks) { + if (!global.__embeddingProgress) return; + const ctx = global.__embeddingProgress; + const event = { + type: "chunk_progress", + workspaceSlug: ctx.workspaceSlug, + filename: ctx.filename, + userId: ctx.userId, + chunksProcessed, + totalChunks, + silent: true, + }; + + if (typeof process.send === "function") { + try { + process.send(event); + } catch {} + return; + } + + const { emitProgress } = require("../EmbeddingWorkerManager"); + emitProgress(ctx.workspaceSlug, event); +} + function humanFileSize(bytes, si = false, dp = 1) { const thresh = si ? 1000 : 1024; @@ -569,4 +604,5 @@ module.exports = { getLLMProvider, toChunks, humanFileSize, + reportEmbeddingProgress, }; diff --git a/server/utils/helpers/updateENV.js b/server/utils/helpers/updateENV.js index e0154c8c2..ec171e055 100644 --- a/server/utils/helpers/updateENV.js +++ b/server/utils/helpers/updateENV.js @@ -1334,8 +1334,15 @@ function dumpENV() { // Allow disabling of streaming for AWS Bedrock "AWS_BEDROCK_STREAMING_DISABLED", - // Allow native tool calling for specific providers. + // Allow capabilities for specific providers. "PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING", + "PROVIDER_SUPPORTS_REASONING", + "PROVIDER_SUPPORTS_IMAGE_GENERATION", + "PROVIDER_SUPPORTS_VISION", + "GENERIC_OPEN_AI_REPORT_USAGE", + + // Allow auto-approval of skills + "AGENT_AUTO_APPROVED_SKILLS", ]; // Simple sanitization of each value to prevent ENV injection via newline or quote escaping. diff --git a/server/utils/telegramBot/constants.js b/server/utils/telegramBot/constants.js index e744c0093..7dc56f78a 100644 --- a/server/utils/telegramBot/constants.js +++ b/server/utils/telegramBot/constants.js @@ -1,7 +1,8 @@ /** * Minimum interval between Telegram message edits (ms) to avoid rate limiting + * https://core.telegram.org/bots/faq#my-bot-is-hitting-limits-how-do-i-avoid-this */ -const STREAM_EDIT_INTERVAL = 600; +const STREAM_EDIT_INTERVAL = 1_200; /** * Telegram messages cap at 4096 chars. We use 4000 to leave headroom diff --git a/server/utils/telegramBot/index.js b/server/utils/telegramBot/index.js index ba9def5d2..98a594dd3 100644 --- a/server/utils/telegramBot/index.js +++ b/server/utils/telegramBot/index.js @@ -29,9 +29,48 @@ const { class TelegramBotService { static _instance = null; + static #MAX_POLLING_RETRIES = 10; + static #BASE_RETRY_DELAY_MS = 1000; + static #MAX_RETRY_DELAY_MS = 5 * 60 * 1000; // 5 minutes + static #NETWORK_ERROR_PATTERNS = [ + "EPIPE", + "EPROTO", + "ECONNABORTED", + "EHOSTDOWN", + "ENETDOWN", + "EADDRNOTAVAIL", + "ETIMEDOUT", + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "ENETUNREACH", + "EHOSTUNREACH", + "EAI_AGAIN", + "EFATAL", + "socket hang up", + "network", + "timeout", + "bad gateway", + "flood", + "429", + "409", + "500", + "501", + "502", + "503", + "504", + "520", + "521", + "522", + "523", + "524", + ]; + + /** @type {TelegramBot|null} */ #bot = null; #config = null; #queue = new MessageQueue(); + #pollingRetry = { timer: null, count: 0 }; // Per-chat state: { workspaceSlug, threadSlug } #chatState = new Map(); // Pending pairing requests: chatId -> { code, telegramUsername, firstName } @@ -138,8 +177,20 @@ class TelegramBotService { Object.assign(this.#config, updates); } + /** + * Stop the bot and clear all state. + * @returns {Promise} + */ async stop() { if (!this.#bot) return; + + // Clear any pending retry timer + if (this.#pollingRetry.timer) { + clearTimeout(this.#pollingRetry.timer); + this.#pollingRetry.timer = null; + } + this.#pollingRetry.count = 0; + try { await this.#bot.stopPolling(); } catch { @@ -171,23 +222,77 @@ class TelegramBotService { } /** - * Handle polling errors with special handling for 401 Unauthorized. - * - 401 errors: Self-cleanup and delete connector - * - Other HTTP error codes: Stop polling immediately + * Check if an error is a transient network issue that warrants retry. + */ + #isNetworkError(error) { + const msg = (error.message || "").toLowerCase(); + return TelegramBotService.#NETWORK_ERROR_PATTERNS.some( + (p) => msg.includes(p.toLowerCase()) || error.code === p + ); + } + + /** + * Handle polling errors with retry logic for network issues. + * - 401 errors: Self-cleanup and delete connector (token invalid) + * - Network errors (ETIMEDOUT, ECONNRESET, etc.): Retry with exponential backoff + * - Other errors: Stop polling immediately */ async #handlePollingError(error) { + // Ignore errors while already waiting to retry + if (this.#pollingRetry.timer) return; this.#log("Polling error:", error.message); + + // 401 = invalid token, cleanup and stop if (error.message?.includes("401")) { this.#log( - "Got 401 - bot token may be invalid. Stopping polling and deleting connector." + "Got 401 - bot token invalid. Stopping and deleting connector." ); return this.#selfCleanup("401 Unauthorized"); } - this.#log( - `Got HTTP error ${error.message}. Stopping polling to prevent further errors.` + // For non-network errors, stop immediately, but don't delete the connector + if (!this.#isNetworkError(error)) { + this.#log(`Got HTTP error ${error.message}. Stopping polling.`); + return await this.stop(); + } + + // Network error - attempt retry with exponential backoff + const maxRetries = TelegramBotService.#MAX_POLLING_RETRIES; + this.#pollingRetry.count++; + if (this.#pollingRetry.count > maxRetries) { + this.#log( + `Network error. Max retries (${maxRetries}) exceeded. Stopping.` + ); + this.#pollingRetry.count = 0; + return await this.stop(); + } + + const delay = Math.min( + TelegramBotService.#BASE_RETRY_DELAY_MS * + Math.pow(2, this.#pollingRetry.count - 1), + TelegramBotService.#MAX_RETRY_DELAY_MS ); - return this.stop(); + this.#log( + `Network error. Retry ${this.#pollingRetry.count}/${maxRetries} in ${Math.round(delay / 1000)}s...` + ); + + this.#pollingRetry.timer = setTimeout(async () => { + this.#pollingRetry.timer = null; + if (!this.#bot || !this.#config) return; + + try { + await this.#bot.stopPolling(); + } catch {} + + this.#log("Attempting to restart polling..."); + try { + await this.#bot.startPolling(); + this.#log("Polling restarted successfully."); + } catch (err) { + this.#log("Failed to restart polling:", err.message); + await this.stop(); + } + }, delay); } /** @@ -321,10 +426,21 @@ class TelegramBotService { return false; } + /** + * Reset the polling retry state and clear the timer if it exists. + */ + #resetPollingRetry() { + this.#pollingRetry.count = 0; + if (this.#pollingRetry.timer) clearTimeout(this.#pollingRetry.timer); + this.#pollingRetry.timer = null; + } + #setupHandlers() { const ctx = this.#createContext(); const guard = async (msg, handler) => { if (!this.#config) return; + this.#resetPollingRetry(); // Reset the polling on successful message receipt + if (!isVerified(this.#config.approved_users, msg.chat.id)) { sendPairingRequest(this.#bot, msg, this.#pendingPairings); return; diff --git a/server/utils/telegramBot/utils/format.js b/server/utils/telegramBot/utils/format.js index 1a907882b..1ab01f361 100644 --- a/server/utils/telegramBot/utils/format.js +++ b/server/utils/telegramBot/utils/format.js @@ -5,9 +5,13 @@ * @param {string} text - The markdown text to convert * @param {object} [opts] * @param {boolean} [opts.escapeHtml=true] - Whether to escape HTML in non-code text + * @param {boolean} [opts.closeUnclosedTags=true] - Whether to close unclosed HTML tags * @returns {string} - HTML formatted text for Telegram */ -function markdownToTelegram(text, { escapeHtml = true } = {}) { +function markdownToTelegram( + text, + { escapeHtml = true, closeUnclosedTags = true } = {} +) { if (!text) return ""; let result = text; @@ -113,6 +117,9 @@ function markdownToTelegram(text, { escapeHtml = true } = {}) { result = result.replace(`\x00INLINECODE${i}\x00`, code); }); + // Close any unclosed HTML tags to prevent Telegram API errors during streaming + // since if you try to update a message with an unclosed tag, the API will return an 400 error + if (closeUnclosedTags) result = closeUnclosedHtmlTags(result); return result; } @@ -128,6 +135,44 @@ function escapeHTML(text) { .replace(/>/g, ">"); } +/** + * Close any unclosed HTML tags to prevent Telegram API errors. + * This is important during streaming when messages may be split mid-markdown. + * @param {string} html - The HTML text to fix + * @returns {string} - HTML with all tags properly closed + */ +function closeUnclosedHtmlTags(html) { + const tags = ["b", "i", "u", "s", "code", "pre", "a", "blockquote"]; + const openTags = []; + const tagRegex = /<\/?([a-z]+)(?:\s[^>]*)?\s*\/?>/gi; + let match; + + while ((match = tagRegex.exec(html)) !== null) { + const fullMatch = match[0]; + const tagName = match[1].toLowerCase(); + + if (!tags.includes(tagName)) continue; + if (fullMatch.endsWith("/>")) continue; + + if (fullMatch.startsWith("= 0; i--) { + result += ``; + } + + return result; +} + /** * Convert a markdown table to aligned preformatted text. * @param {string} tableMarkdown diff --git a/server/yarn.lock b/server/yarn.lock index 9cbd05d24..e1730b95b 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -2254,6 +2254,54 @@ dependencies: ws "^7.5.10" +"@mintplex-labs/mdpdf-darwin-arm64@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-darwin-arm64/-/mdpdf-darwin-arm64-0.1.9.tgz#36fbf70f3c0c331db42622e50599f20fab621f2c" + integrity sha512-hM8LjpmW3/wOgUzhMXsI4B4vEJKun0wU/BS8VnCANmfObjErKxFwBZm7PYqz43Z4BF5JMZx/DXKWg62Y+onWRA== + +"@mintplex-labs/mdpdf-darwin-x64@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-darwin-x64/-/mdpdf-darwin-x64-0.1.9.tgz#fdeb42bbb8f172d060e6151c194eb6a5f38efa8f" + integrity sha512-Iq+nfTZTfG+o4Ub7Sjy61LeLbUk3RzM6VvuC5OijamyJjmyUvKZU3bLTwTonUNRgOBPSlFF0gjbaYhixQSr90A== + +"@mintplex-labs/mdpdf-linux-arm64-gnu@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-linux-arm64-gnu/-/mdpdf-linux-arm64-gnu-0.1.9.tgz#c4fe2a480b9f8341bcb1bebeca90cafae51293ae" + integrity sha512-MJPD7RYqJRbrSbXFM72zFJdFMBMBut0HScuKtHlILme17gK/DGOb8croNGwLH1V2rKQ5gVNWFSWIzoIF8Q7R3Q== + +"@mintplex-labs/mdpdf-linux-x64-gnu@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-linux-x64-gnu/-/mdpdf-linux-x64-gnu-0.1.9.tgz#b816fcd071fcb5158c0c73516a6ffe486198b17c" + integrity sha512-7NwBhKjNjpX54n4CLhx/yNT+mrSEwRurC07fy9+GXT0BXPqyinBNwnT0r2iabMPJ+p98zcc3op9JeTKpSqE6iA== + +"@mintplex-labs/mdpdf-linux-x64-musl@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-linux-x64-musl/-/mdpdf-linux-x64-musl-0.1.9.tgz#8c294af85fb9fc75e5c85048484691fe320daaf5" + integrity sha512-sqaLe1ANYdLul8V6u8BMHkx6RahRW+LkMZDrBELlz+LBC6IhgOTV1VPXrgLv6C1nK9ZIAuBlm3tyDA1OpW/iXA== + +"@mintplex-labs/mdpdf-win32-arm64-msvc@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-win32-arm64-msvc/-/mdpdf-win32-arm64-msvc-0.1.9.tgz#bbab7da24467d63d5fc1d11a1f6f428a7ea99894" + integrity sha512-ETxE7uXCKbGyBsapKbYb1OW6qp80lKtKB+4sXgR60u5mOiERdJp87es1rTqcZ7pTadQU2VPrkRNpjoS4slt85Q== + +"@mintplex-labs/mdpdf-win32-x64-msvc@0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf-win32-x64-msvc/-/mdpdf-win32-x64-msvc-0.1.9.tgz#89b0899e58373b6836c0b529d938fb4b6e6585cd" + integrity sha512-FRCAxzJHp93EQxtKZDq78A+cBu2DQlaNSYZMoq6v5RBQZxw0/fIDNnzPPehzs5HWwbv++GxU7NxxSn2bRqVkWA== + +"@mintplex-labs/mdpdf@^0.1.9": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@mintplex-labs/mdpdf/-/mdpdf-0.1.9.tgz#a5f8a04e355195214c6492c47bdc650fa5d6b29c" + integrity sha512-EyJfgzZisEETrTg85KgtUQJ2CijzWkKidpfep3zsaFEY2x11yQeEyQeH4QmN34cWm1Xpk6cCjmvrJ8RixnNn9A== + optionalDependencies: + "@mintplex-labs/mdpdf-darwin-arm64" "0.1.9" + "@mintplex-labs/mdpdf-darwin-x64" "0.1.9" + "@mintplex-labs/mdpdf-linux-arm64-gnu" "0.1.9" + "@mintplex-labs/mdpdf-linux-x64-gnu" "0.1.9" + "@mintplex-labs/mdpdf-linux-x64-musl" "0.1.9" + "@mintplex-labs/mdpdf-win32-arm64-msvc" "0.1.9" + "@mintplex-labs/mdpdf-win32-x64-msvc" "0.1.9" + "@modelcontextprotocol/sdk@^1.24.3": version "1.24.3" resolved "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.24.3.tgz"
+ {t("api.table.name")} + {t("api.table.key")} @@ -97,15 +100,15 @@ export default function AdminApiKeys() { {t("api.table.created")} - {" "} + {t("api.actions")}
- No API keys found + + {t("api.empty")}