diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000000..8d24883355c --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,46 @@ +name: Build & Push Lovora Image + +on: + push: + branches: [master] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set image tags + id: vars + run: | + SHORT_SHA="${GITHUB_SHA::8}" + echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT" + + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build & push + uses: docker/build-push-action@v6 + id: build + with: + context: . + file: ./ramsli-custom/full-source.Dockerfile + push: true + platforms: linux/amd64 + tags: | + andreasramsli/lovora:latest + andreasramsli/lovora:${{ steps.vars.outputs.short_sha }} + no-cache: true + + - name: Verify backend code in built image + run: | + docker pull andreasramsli/lovora:${{ steps.vars.outputs.short_sha }} + docker run --rm --entrypoint /bin/bash andreasramsli/lovora:${{ steps.vars.outputs.short_sha }} -lc \ + "grep -n 'VALID_CHAT_MODES' /app/server/models/workspace.js && grep -n 'automatic' /app/server/models/workspace.js" diff --git a/.gitignore b/.gitignore index f4e44193206..4789678f75d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,9 @@ aws_cf_deploy_anything_llm.json yarn.lock *.bak .idea +.local-storage/ +.local-dev/ +frontend/.env +server/.env.development +collector/.env.development +.gstack/ diff --git a/README.md b/README.md index 42c7db916d9..c92bc405a23 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,21 @@ Mintplex Labs & the community maintain a number of deployment methods, scripts, - `yarn dev:frontend` To boot the frontend locally (from root of repo). - `yarn dev:collector` To then run the document collector (from root of repo). +### Faster local Lovora workflow + +For day-to-day Lovora iteration, use the helper scripts from the repo root: + +- `yarn local:setup` clones env templates, creates local storage, installs dependencies, and runs Prisma generate/migrate. +- `yarn local:start` starts `server`, `frontend`, and `collector` together. + +If you want the setup script to clone the repo into a fresh directory first: + +```bash +bash ./scripts/local-dev-setup.sh --clone-dir /path/to/lovora-local --repo-url https://github.com/AndreasRamsli/lovora.git +``` + +After setup, add your real provider keys to `server/.env.development` if you want local agent/model behavior to match production. + [Learn about documents](./server/storage/documents/DOCUMENTS.md) [Learn about vector caching](./server/storage/vector-cache/VECTOR_CACHE.md) diff --git a/frontend/index.html b/frontend/index.html index 6eb5b43c8a1..5d420d0c43d 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,26 +5,22 @@ - AnythingLLM | Your personal LLM trained on anything + Lovora | Your personal AI assistant - - + + - - - - + + + - - - - + + + diff --git a/frontend/src/LogoContext.jsx b/frontend/src/LogoContext.jsx index 6cbf0ea32a4..93573348b2d 100644 --- a/frontend/src/LogoContext.jsx +++ b/frontend/src/LogoContext.jsx @@ -1,8 +1,6 @@ import { createContext, useEffect, useState } from "react"; -import AnythingLLM from "./media/logo/anything-llm.png"; -import AnythingLLMDark from "./media/logo/anything-llm-dark.png"; -import DefaultLoginLogoLight from "./media/illustrations/login-logo.svg"; -import DefaultLoginLogoDark from "./media/illustrations/login-logo-light.svg"; +import LogoLight from "./media/logo/lovora-light.svg"; +import LogoDark from "./media/logo/lovora-dark.svg"; import System from "./models/system"; export const REFETCH_LOGO_EVENT = "refetch-logo"; @@ -18,23 +16,21 @@ export function LogoProvider({ children }) { const [isCustomLogo, setIsCustomLogo] = useState(false); async function fetchInstanceLogo() { - const DefaultLoginLogo = isLightMode() - ? DefaultLoginLogoDark - : DefaultLoginLogoLight; + const defaultLogo = isLightMode() ? LogoLight : LogoDark; try { const { isCustomLogo, logoURL } = await System.fetchLogo(); if (logoURL) { setLogo(logoURL); - setLoginLogo(isCustomLogo ? logoURL : DefaultLoginLogo); + setLoginLogo(isCustomLogo ? logoURL : defaultLogo); setIsCustomLogo(isCustomLogo); } else { - isLightMode() ? setLogo(AnythingLLMDark) : setLogo(AnythingLLM); - setLoginLogo(DefaultLoginLogo); + setLogo(defaultLogo); + setLoginLogo(defaultLogo); setIsCustomLogo(false); } } catch (err) { - isLightMode() ? setLogo(AnythingLLMDark) : setLogo(AnythingLLM); - setLoginLogo(DefaultLoginLogo); + setLogo(defaultLogo); + setLoginLogo(defaultLogo); setIsCustomLogo(false); console.error("Failed to fetch logo:", err); } diff --git a/frontend/src/components/DefaultChat/index.jsx b/frontend/src/components/DefaultChat/index.jsx index 1024996b152..e7921b8b2e6 100644 --- a/frontend/src/components/DefaultChat/index.jsx +++ b/frontend/src/components/DefaultChat/index.jsx @@ -13,7 +13,7 @@ import { safeJsonParse } from "@/utils/request"; export default function DefaultChatContainer() { const { t } = useTranslation(); const { user } = useUser(); - const { logo } = useLogo(); + const { logo, isCustomLogo } = useLogo(); const [lastVisitedWorkspace, setLastVisitedWorkspace] = useState(null); const [{ workspaces, loading }, setWorkspaces] = useState({ workspaces: [], @@ -77,7 +77,11 @@ export default function DefaultChatContainer() { Custom Logo

{t("home.welcome")}, {user.username}! diff --git a/frontend/src/components/Footer/index.jsx b/frontend/src/components/Footer/index.jsx index cdcd52a979c..2cf6913afae 100644 --- a/frontend/src/components/Footer/index.jsx +++ b/frontend/src/components/Footer/index.jsx @@ -61,7 +61,7 @@ export default function Footer() { > @@ -77,7 +77,7 @@ export default function Footer() { > @@ -93,7 +93,7 @@ export default function Footer() { > diff --git a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx index 46f0bdf1646..621b7ee76a5 100644 --- a/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx +++ b/frontend/src/components/Modals/ManageWorkspace/Documents/WorkspaceDirectory/index.jsx @@ -11,6 +11,7 @@ import Workspace from "@/models/workspace"; import { Tooltip } from "react-tooltip"; import { safeJsonParse } from "@/utils/request"; import { useTranslation } from "react-i18next"; +import { getWorkspaceDisplayName } from "@/utils/workspaceDisplay"; function WorkspaceDirectory({ workspace, @@ -92,7 +93,7 @@ function WorkspaceDirectory({

- {workspace.name} + {getWorkspaceDisplayName(workspace)}

@@ -119,7 +120,7 @@ function WorkspaceDirectory({

- {workspace.name} + {getWorkspaceDisplayName(workspace)}

diff --git a/frontend/src/components/Modals/ManageWorkspace/index.jsx b/frontend/src/components/Modals/ManageWorkspace/index.jsx index cf6186bb4c3..0f950e44b94 100644 --- a/frontend/src/components/Modals/ManageWorkspace/index.jsx +++ b/frontend/src/components/Modals/ManageWorkspace/index.jsx @@ -9,6 +9,7 @@ import useUser from "../../../hooks/useUser"; import DocumentSettings from "./Documents"; import DataConnectors from "./DataConnectors"; import ModalWrapper from "@/components/ModalWrapper"; +import { getWorkspaceDisplayName } from "@/utils/workspaceDisplay"; const noop = () => {}; const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => { @@ -44,7 +45,8 @@ const ManageWorkspace = ({ hideModal = noop, providedSlug = null }) => {

- {t("connectors.manage.editing")} "{workspace.name}" + {t("connectors.manage.editing")} " + {getWorkspaceDisplayName(workspace)}"

@@ -292,37 +292,37 @@ export default function MultiUserAuth() {
-

+

{t("login.multi-user.welcome")}

-

- {t("login.sign-in", { appName: customAppName || "AnythingLLM" })} +

+ {t("login.sign-in", { appName: customAppName || "Lovora" })}

-
-
{references > 1 && ( -

+

Referenced {references} times.

)} @@ -176,7 +176,7 @@ function EditActionBar({ onCancel, onSave, isUserMessage = false }) { diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/RenderMetrics/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/RenderMetrics/index.jsx index 0865ef22183..3efdb15862e 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/RenderMetrics/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/RenderMetrics/index.jsx @@ -131,7 +131,7 @@ export default function RenderMetrics({ metrics = {} }) { } className={`border-none flex md:justify-end items-center gap-x-[8px] -ml-7 ${showMetricsAutomatically ? "opacity-100" : "opacity-0"} md:group-hover:opacity-100 transition-all duration-300`} > -

+

{buildMetricsString(metrics)}

diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/asyncTts.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/asyncTts.jsx index bd6b577e3b7..689b5b5f13f 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/asyncTts.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/asyncTts.jsx @@ -65,7 +65,7 @@ export default function AsyncTTSMessage({ slug, chatId }) { ? t("pause_tts_speech_message") : t("chat_window.tts_speak_message") } - className="border-none text-zinc-300 light:text-slate-500" + className="border-none text-doctor/75 light:text-infinite-night/55" aria-label={speaking ? "Pause speech" : "Speak message"} > {speaking ? ( diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/native.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/native.jsx index d77de797b24..4dc7f7e53bd 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/native.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/TTSButton/native.jsx @@ -41,7 +41,7 @@ export default function NativeTTSMessage({ chatId, message }) { data-tooltip-content={ speaking ? "Pause TTS speech of message" : "TTS Speak message" } - className="border-none text-zinc-300 light:text-slate-500" + className="border-none text-doctor/75 light:text-infinite-night/55" aria-label={speaking ? "Pause speech" : "Speak message"} > {speaking ? ( diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx index 25a550f3d3a..a7204d9a35c 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/Actions/index.jsx @@ -85,7 +85,7 @@ function FeedbackButton({ onClick={handleFeedback} data-tooltip-id="feedback-button" data-tooltip-content={tooltipContent} - className="text-zinc-300 light:text-slate-500" + className="text-doctor/75 light:text-infinite-night/55" aria-label={tooltipContent} > copyText(message)} data-tooltip-id="copy-assistant-text" data-tooltip-content={t("chat_window.copy")} - className="text-zinc-300 light:text-slate-500" + className="text-doctor/75 light:text-infinite-night/55" aria-label={t("chat_window.copy")} > {copied ? ( @@ -132,7 +132,7 @@ function RegenerateMessage({ regenerateMessage, chatId }) { onClick={() => regenerateMessage(chatId)} data-tooltip-id="regenerate-assistant-text" data-tooltip-content={t("chat_window.regenerate_response")} - className="border-none text-zinc-300 light:text-slate-500" + className="border-none text-doctor/75 light:text-infinite-night/55" aria-label={t("chat_window.regenerate")} > diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx index c01eef8543e..6b753bdffcd 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/HistoricalMessage/index.jsx @@ -263,7 +263,7 @@ function TruncatableContent({ children }) { {isOverflowing && ( diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx index 8f09f18aef2..56f0b65ade4 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/PromptReply/index.jsx @@ -46,7 +46,7 @@ const PromptReply = ({ uuid, reply, pending, error, sources = [] }) => { message={reply} messageId={uuid} /> - +
); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx index 977bc27c1b9..b4a881ad4cd 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/ChatHistory/StatusResponse/index.jsx @@ -54,7 +54,7 @@ export default function StatusResponse({ messages = [], isThinking = false }) { {previousThoughts?.length > 0 && ( - {showTooltip && ( + + {hasParsedFiles && ( clearCloseTimer()} + afterHide={() => { + setIsTooltipHovered(false); + }} > - +
{ + clearCloseTimer(); + setIsTooltipHovered(true); + }} + onPointerLeave={() => { + setIsTooltipHovered(false); + }} + > + { + clearCloseTimer(); + setIsTooltipOpen(false); + setIsTooltipHovered(false); + }} + isLoading={isLoading} + files={files} + setFiles={setFiles} + currentTokens={currentTokens} + setCurrentTokens={setCurrentTokens} + contextWindow={contextWindow} + workspaceSlug={slug} + threadSlug={threadSlug} + /> +
)} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/ChatModelSelection/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/ChatModelSelection/index.jsx index cdc62e16eea..0b12d7739f5 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/ChatModelSelection/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/ChatModelSelection/index.jsx @@ -17,7 +17,7 @@ export default function ChatModelSelection({
@@ -33,7 +33,7 @@ export default function LLMSelectorSidePanel({ data-llm-value={llm.value} className={`border-none cursor-pointer flex gap-2 items-center px-2.5 py-1.5 rounded-md transition-colors ${ selectedLLMProvider === llm.value - ? "bg-zinc-700 light:bg-slate-200" + ? "bg-zinc-700 light:bg-divine-pleasure" : "hover:bg-zinc-700/50 light:hover:bg-slate-100 bg-transparent" }`} onClick={() => onProviderClick(llm.value)} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/SetupProvider/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/SetupProvider/index.jsx index 09f38addfbf..6ca639f619e 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/SetupProvider/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/LLMSelector/SetupProvider/index.jsx @@ -96,9 +96,9 @@ export function NoSetupWarning({ showing, onSetupClick }) {
-

+

{t("chat_window.workspace_llm_manager.missing_credentials")}{" "} -

+

{t("chat_window.workspace_llm_manager.loading_workspace_settings")}

@@ -127,12 +127,12 @@ export default function LLMSelectorModal({
-

+

{t("chat_window.workspace_llm_manager.available_models", { provider: providerName, })}

-

+

{t( "chat_window.workspace_llm_manager.available_models_description" )} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SpeechToText/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SpeechToText/index.jsx index 9265baa0135..71ad8a8da0f 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SpeechToText/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/SpeechToText/index.jsx @@ -125,16 +125,15 @@ export default function SpeechToText({ sendCommand }) { data-tooltip-content={`${t("chat_window.microphone")} (CTRL + M)`} aria-label={t("chat_window.microphone")} onClick={listening ? endSTTSession : startSTTSession} - className={`group border-none relative flex justify-center items-center cursor-pointer w-8 h-8 rounded-full hover:bg-zinc-700 light:hover:bg-slate-200 ${ - listening ? "bg-zinc-700 light:bg-slate-200" : "" + className={`group border-none relative flex justify-center items-center cursor-pointer w-8 h-8 rounded-full hover:bg-zinc-700 light:hover:bg-divine-pleasure ${ + listening ? "bg-zinc-700 light:bg-divine-pleasure" : "" }`} > diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx index 12006898126..d2f9d519ec3 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/AgentSkills/SkillRow/index.jsx @@ -6,17 +6,18 @@ export default function SkillRow({ onToggle, highlighted = false, disabled = false, + disabledTooltipId = null, + disabledTooltip = null, }) { - let classNames = "flex items-center justify-between px-2 py-1 rounded"; - if (highlighted) classNames += " bg-zinc-700/50 light:bg-slate-100"; - else classNames += " hover:bg-zinc-700/50 light:hover:bg-slate-100"; - - if (disabled) classNames += " opacity-60 cursor-not-allowed"; - else classNames += " cursor-pointer"; return (

{name} { fetchSkillSettings(); @@ -147,11 +150,6 @@ export default function AgentSkillsTab({ return ( <> - {!agentSessionActive && ( -

- {t("chat_window.use_agent_session_to_use_tools")} -

- )} {items.map((item, index) => ( ))} @@ -170,6 +174,14 @@ export default function AgentSkillsTab({ + {agentSessionActive && ( + + )} ); } diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx index 2af07532887..8daa5b6d060 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashCommandRow/index.jsx @@ -1,5 +1,4 @@ import { useState, useRef, useEffect } from "react"; -import { createPortal } from "react-dom"; import { DotsThree } from "@phosphor-icons/react"; import { useTranslation } from "react-i18next"; @@ -14,7 +13,6 @@ export default function SlashCommandRow({ }) { const { t } = useTranslation(); const [menuOpen, setMenuOpen] = useState(false); - const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 }); const menuRef = useRef(null); const menuBtnRef = useRef(null); @@ -34,16 +32,6 @@ export default function SlashCommandRow({ return () => document.removeEventListener("mousedown", handleClickOutside); }, [menuOpen]); - useEffect(() => { - if (menuOpen && menuBtnRef.current) { - const rect = menuBtnRef.current.getBoundingClientRect(); - setMenuPosition({ - top: rect.bottom + window.scrollY, - left: rect.right + window.scrollX - 120, - }); - } - }, [menuOpen]); - return (
{command} - + {description}
@@ -71,47 +59,40 @@ export default function SlashCommandRow({ e.stopPropagation(); setMenuOpen(!menuOpen); }} - className="border-none cursor-pointer text-zinc-400 light:text-slate-500 p-0.5 hover:text-white light:hover:text-slate-900 rounded opacity-0 group-hover:opacity-100" + className="border-none cursor-pointer text-doctor/55 light:text-infinite-night/55 p-0.5 hover:text-white light:hover:text-slate-900 rounded opacity-0 group-hover:opacity-100" > - {menuOpen && - createPortal( -
+ + - -
, - document.body - )} + {t("chat_window.publish")} + +
+ )}
)}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/index.jsx new file mode 100644 index 00000000000..1ea231102b4 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/Tabs/SlashCommands/SlashPresets/index.jsx @@ -0,0 +1 @@ +export const CMD_REGEX = /[^a-zA-Z0-9_-]/g; diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx index a5146702640..b53a01991eb 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/ToolsMenu/index.jsx @@ -5,6 +5,7 @@ import AgentSkillsTab from "./Tabs/AgentSkills"; import SlashCommandsTab from "./Tabs/SlashCommands"; export const TOOLS_MENU_KEYBOARD_EVENT = "tools-menu-keyboard"; + function getTabs(t, user) { const tabs = [ { @@ -14,7 +15,6 @@ function getTabs(t, user) { }, ]; - // Only show agent skills tab for admins or when multiuser mode is off const canSeeAgentSkills = !user?.hasOwnProperty("role") || user.role === "admin"; if (canSeeAgentSkills) { @@ -50,20 +50,14 @@ export default function ToolsMenu({ const [highlightedIndex, setHighlightedIndex] = useState(-1); const itemCountRef = useRef(0); - // Always open to the slash commands - useEffect(() => { - if (showing) setActiveTab(TABS[0].key); - }, [showing]); - // Reset highlight when switching tabs or closing useEffect(() => { setHighlightedIndex(-1); }, [activeTab, showing]); - // Keep the parent ref in sync so PromptInput can check it on Enter useEffect(() => { if (highlightedIndexRef) highlightedIndexRef.current = highlightedIndex; - }, [highlightedIndex]); + }, [highlightedIndex, highlightedIndexRef]); const registerItemCount = useCallback((count) => { itemCountRef.current = count; @@ -118,8 +112,6 @@ export default function ToolsMenu({ />
{ - // Prevents prompt textarea from losing focus when clicking inside the menu. - // Skip for portaled modals so their inputs can still receive focus. if (e.currentTarget.contains(e.target)) e.preventDefault(); }} className={`absolute left-2 right-2 md:left-14 md:right-auto md:w-[400px] z-50 bg-zinc-800 light:bg-white border border-zinc-700 light:border-slate-300 rounded-lg p-3 flex flex-col gap-2.5 shadow-lg overflow-hidden ${ @@ -161,8 +153,8 @@ function TabButton({ active, onClick, children }) { onClick={onClick} className={`border-none cursor-pointer hover:bg-zinc-700/50 light:hover:bg-slate-100 px-1.5 py-0.5 rounded text-[10px] font-medium text-center whitespace-nowrap ${ active - ? "bg-zinc-700 text-white light:bg-slate-200 light:text-slate-800" - : "text-zinc-400 light:text-slate-800" + ? "bg-zinc-700 text-white light:bg-divine-pleasure light:text-infinite-night" + : "text-doctor/55 light:text-infinite-night" }`} > {children} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx index 018c218b038..250914207ca 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/PromptInput/index.jsx @@ -17,7 +17,10 @@ import Appearance from "@/models/appearance"; import usePromptInputStorage from "@/hooks/usePromptInputStorage"; import ToolsMenu, { TOOLS_MENU_KEYBOARD_EVENT } from "./ToolsMenu"; import { useSearchParams } from "react-router-dom"; +<<<<<<< HEAD +======= import { useIsAgentSessionActive } from "@/utils/chat/agent"; +>>>>>>> upstream/master export const PROMPT_INPUT_ID = "primary-prompt-input"; export const PROMPT_INPUT_EVENT = "set_prompt_input"; @@ -46,7 +49,10 @@ export default function PromptInput({ const agentSessionActive = useIsAgentSessionActive(); const [promptInput, setPromptInput] = useState(""); const [showTools, setShowTools] = useState(false); +<<<<<<< HEAD +======= const autoOpenedToolsRef = useRef(false); +>>>>>>> upstream/master const toolsHighlightRef = useRef(-1); const formRef = useRef(null); const textareaRef = useRef(null); @@ -116,7 +122,10 @@ export default function PromptInput({ const debouncedSaveState = debounce(saveCurrentState, 250); function handleSubmit(e) { +<<<<<<< HEAD +======= // Ignore submits from portaled modals (slash command preset forms) +>>>>>>> upstream/master if (e.target !== e.currentTarget) return; setFocused(false); setShowTools(false); @@ -147,8 +156,11 @@ export default function PromptInput({ ); return; } +<<<<<<< HEAD +======= // When an item is highlighted via arrow keys, Enter selects it. // Otherwise, Enter falls through to submit the form normally. +>>>>>>> upstream/master if (event.key === "Enter" && toolsHighlightRef.current >= 0) { event.preventDefault(); window.dispatchEvent( @@ -173,10 +185,14 @@ export default function PromptInput({ !event.metaKey && promptInput.trim() === "" ) { +<<<<<<< HEAD + setShowTools((prev) => !prev); +======= setShowTools((prev) => { autoOpenedToolsRef.current = !prev; return !prev; }); +>>>>>>> upstream/master return; } @@ -356,11 +372,41 @@ export default function PromptInput({ }} value={promptInput} spellCheck={Appearance.get("enableSpellCheck")} - className={`border-none cursor-text max-h-[50vh] md:max-h-[350px] md:min-h-[40px] pt-[20px] w-full leading-5 text-white light:text-slate-600 bg-transparent placeholder:text-white/60 light:placeholder:text-slate-400 resize-none active:outline-none focus:outline-none flex-grow pwa:!text-[16px] ${textSizeClass}`} + className={`border-none cursor-text max-h-[50vh] md:max-h-[350px] md:min-h-[40px] pt-[20px] w-full leading-5 text-white light:text-infinite-night/55 bg-transparent placeholder:text-white/60 light:placeholder:text-infinite-night/40 resize-none active:outline-none focus:outline-none flex-grow pwa:!text-[16px] ${textSizeClass}`} placeholder={t("chat_window.send_message")} />
+<<<<<<< HEAD +
+ + +=======
+>>>>>>> upstream/master
{isStreaming ? ( ) : ( +<<<<<<< HEAD + <> + + + +======= +>>>>>>> upstream/master )}
diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/SourceDetailView/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/SourceDetailView/index.jsx index 6e4bc33b2a8..eca3b1fd369 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/SourceDetailView/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/SourceDetailView/index.jsx @@ -1,20 +1,21 @@ -import { Fragment } from "react"; -import { CaretLeft, Info, X } from "@phosphor-icons/react"; -import { decode as HTMLDecode } from "he"; +import { CaretLeft, X } from "@phosphor-icons/react"; import truncate from "truncate"; -import { useTranslation } from "react-i18next"; -import { omitChunkHeader } from "../../../ChatHistory/Citation"; -import { toPercentString } from "@/utils/numbers"; +import SourceDetailBody from "../../SourceDetailBody"; -export default function SourceDetailView({ source, onBack, onClose }) { - const { t } = useTranslation(); +export default function SourceDetailView({ + source, + workspaceSlug = null, + threadSlug = null, + onBack, + onClose, +}) { return ( <>
@@ -24,32 +25,17 @@ export default function SourceDetailView({ source, onBack, onClose }) {
-
- {source.chunks.map(({ text, score }, idx) => ( - -
-

- {HTMLDecode(omitChunkHeader(text))} -

- {!!score && ( -
- -

- {toPercentString(score)} {t("chat_window.similarity_match")} -

-
- )} -
- {idx !== source.chunks.length - 1 && ( -
- )} -
- ))} +
+
); diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx index 1ba07ec5599..bbed95f6d9d 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/MobileCitationModal/index.jsx @@ -10,6 +10,8 @@ export default function MobileCitationModal({ isOpen, selectedSource, setSelectedSource, + workspaceSlug = null, + threadSlug = null, onClose, }) { const sources = combineLikeSources(rawSources); @@ -22,6 +24,8 @@ export default function MobileCitationModal({ {selectedSource ? ( setSelectedSource(null)} onClose={onClose} /> @@ -34,7 +38,7 @@ export default function MobileCitationModal({ diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceDetailBody.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceDetailBody.jsx new file mode 100644 index 00000000000..be78c28f894 --- /dev/null +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceDetailBody.jsx @@ -0,0 +1,336 @@ +import { Fragment, useEffect, useState } from "react"; +import { ArrowSquareOut, Info, SpinnerGap } from "@phosphor-icons/react"; +import { decode as HTMLDecode } from "he"; +import Workspace from "@/models/workspace"; +import renderMarkdown from "@/utils/chat/markdown"; +import DOMPurify from "@/utils/chat/purify"; +import { toPercentString } from "@/utils/numbers"; +import { omitChunkHeader } from "../ChatHistory/Citation"; + +function decodeChunkText(text = "") { + return HTMLDecode(omitChunkHeader(text)).trim(); +} + +function isHttpUrl(value = "") { + return /^https?:\/\//i.test(String(value).trim()); +} + +function isSourceFieldUrl(label = "", value = "") { + return label.trim().toLowerCase() === "kilde" && isHttpUrl(value); +} + +function parseLeadingSourceHeader(pageContent = "") { + const lines = pageContent.split(/\r?\n/); + let start = 0; + + while (start < lines.length && !lines[start].trim()) start += 1; + if (start >= lines.length) return null; + + const titleLine = lines[start].trim(); + if (!titleLine || titleLine.includes(":")) return null; + + const fields = []; + let cursor = start + 1; + + while (cursor < lines.length) { + const currentLine = lines[cursor].trim(); + if (!currentLine) { + cursor += 1; + if (fields.length) break; + continue; + } + + const match = currentLine.match(/^([^:]{1,80}):\s*(.+)$/); + if (!match) break; + + fields.push({ + label: match[1].trim(), + value: match[2].trim(), + }); + cursor += 1; + } + + if ( + fields.length < 2 || + !fields.some((field) => field.label.toLowerCase() === "kilde") + ) { + return null; + } + + return { + titleLine, + fields, + body: lines.slice(cursor).join("\n").trim(), + }; +} + +function normalizeSourceText(text = "") { + return text.replace( + /^(Kilde:\s*)(https?:\/\/\S+)(.*)$/gim, + (_, label, url, suffix = "") => `${label}[${url}](${url})${suffix}` + ); +} + +function SourceMarkdownText({ text, className = "" }) { + const sourceHtml = DOMPurify.sanitize( + renderMarkdown(normalizeSourceText(text)) + ); + + return ( +
+ ); +} + +function renderSourceFieldValue(field = {}) { + if (isSourceFieldUrl(field.label, field.value)) { + return ( + + {field.value} + + + ); + } + + return ( + + ); +} + +function renderSourceBodyText(sourceDocument = {}, parsedSource = null) { + const text = parsedSource ? parsedSource.body : sourceDocument.pageContent; + if (!text) return null; + + return ( + + ); +} + +function getExternalSourceLink(source = {}, sourceDocument = null) { + const candidates = [ + source?.url, + source?.docSource, + sourceDocument?.metadata?.url, + sourceDocument?.metadata?.docSource, + ].filter(Boolean); + + for (const candidate of candidates) { + if (isHttpUrl(candidate)) return candidate; + } + + return null; +} + +function SourceDocumentSection({ sourceDocument }) { + const parsedSource = parseLeadingSourceHeader( + sourceDocument?.pageContent || "" + ); + + if (!sourceDocument?.pageContent) return null; + + return ( +
+
+
+

+ Source document +

+

+ {parsedSource?.titleLine || sourceDocument.title} +

+
+ {sourceDocument?.metadata?.location && ( + + {sourceDocument.metadata.location} + + )} +
+ + {!!parsedSource?.fields?.length && ( +
+
+ {parsedSource.fields.map((field) => ( +
+ + {field.label} + + {renderSourceFieldValue(field)} +
+ ))} +
+
+ )} + +
+ {renderSourceBodyText(sourceDocument, parsedSource)} +
+
+ ); +} + +export default function SourceDetailBody({ + source, + workspaceSlug = null, + threadSlug = null, +}) { + const [loadState, setLoadState] = useState({ + status: "idle", + sourceDocument: null, + }); + + useEffect(() => { + setLoadState({ + status: "idle", + sourceDocument: null, + }); + }, [ + source?.title, + source?.published, + source?.location, + source?.chunks?.[0]?.id, + ]); + + async function loadSourceDocument() { + if (loadState.status === "loading" || loadState.status === "loaded") return; + if (!workspaceSlug) { + setLoadState({ status: "unavailable", sourceDocument: null }); + return; + } + + setLoadState({ status: "loading", sourceDocument: null }); + try { + const primaryChunk = source?.chunks?.[0] || {}; + const { response, data } = await Workspace.getCitationSource( + workspaceSlug, + { + title: source?.title || null, + published: source?.published || null, + chunkSource: primaryChunk.chunkSource || null, + location: source?.location || null, + }, + threadSlug + ); + + if (response.ok && data?.mode === "full" && data?.pageContent) { + setLoadState({ + status: "loaded", + sourceDocument: data, + }); + return; + } + + setLoadState({ status: "unavailable", sourceDocument: null }); + } catch (error) { + console.error("Failed to load citation source document.", error); + setLoadState({ status: "unavailable", sourceDocument: null }); + } + } + + const externalSourceLink = getExternalSourceLink( + source, + loadState.sourceDocument + ); + + return ( +
+ {externalSourceLink && ( +
+

+ Original source +

+ + {externalSourceLink} + + +
+ )} + +
+
+
+

+ Relevant excerpts +

+

+ Chunks are shown first so the cited passages stay easy to scan. +

+
+ +
+ + {source.chunks.map(({ text, score }, idx) => { + const decodedText = decodeChunkText(text); + return ( + +
+ + {!!score && ( +
+
+ +

{toPercentString(score)} similarity match

+
+
+ )} +
+ {idx !== source.chunks.length - 1 && ( +
+ )} +
+ ); + })} +
+ + {loadState.status === "unavailable" && ( +
+ Full source text is unavailable for this citation. The excerpts above + are still the retrieved passages used in the answer. +
+ )} + + {loadState.status === "loaded" && ( + + )} +
+ ); +} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx index 03fb619c3b5..4dc4586b1f3 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/SourceItem/index.jsx @@ -13,17 +13,12 @@ export default function SourceItem({ source, onClick }) { className="flex flex-col gap-[2px] items-start w-full text-left hover:opacity-75 transition-opacity" >
- +

{source.title}

-
+

{subtitle}

{t("chat_window.source_count", { count: source.references })}

diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/index.jsx index 02cc1c354b7..9824e9aeceb 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/SourcesSidebar/index.jsx @@ -1,4 +1,4 @@ -import { createContext, useContext, useState } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; import { isMobile } from "react-device-detect"; import { useTranslation } from "react-i18next"; import { X } from "@phosphor-icons/react"; @@ -11,22 +11,37 @@ import SourceItem from "./SourceItem"; export const SourcesSidebarContext = createContext(); -export function SourcesSidebarProvider({ children }) { +export function SourcesSidebarProvider({ + children, + workspaceSlug = null, + threadSlug = null, +}) { const [sources, setSources] = useState([]); const [sidebarOpen, setSidebarOpen] = useState(false); + const [activeCitationId, setActiveCitationId] = useState(null); - function openSidebar(newSources) { + function openSidebar(newSources, citationId = null) { setSources(newSources); + setActiveCitationId(citationId); setSidebarOpen(true); } function closeSidebar() { setSidebarOpen(false); + setActiveCitationId(null); } return ( {children} @@ -38,10 +53,15 @@ export function useSourcesSidebar() { } export default function SourcesSidebar() { - const { sources, sidebarOpen, closeSidebar } = useSourcesSidebar(); + const { sources, sidebarOpen, closeSidebar, workspaceSlug, threadSlug } = + useSourcesSidebar(); const { t } = useTranslation(); const [selectedSource, setSelectedSource] = useState(null); + useEffect(() => { + setSelectedSource(null); + }, [sources, sidebarOpen]); + const combined = combineLikeSources(sources); if (isMobile) { @@ -51,6 +71,8 @@ export default function SourcesSidebar() { isOpen={sidebarOpen} selectedSource={selectedSource} setSelectedSource={setSelectedSource} + workspaceSlug={workspaceSlug} + threadSlug={threadSlug} onClose={() => { setSelectedSource(null); closeSidebar(); @@ -76,7 +98,7 @@ export default function SourcesSidebar() { @@ -95,6 +117,8 @@ export default function SourcesSidebar() { {selectedSource && ( setSelectedSource(null)} /> )} diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/TextSizeMenu/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/TextSizeMenu/index.jsx index 919ee5727ea..52e5a73d78e 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/TextSizeMenu/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/TextSizeMenu/index.jsx @@ -57,16 +57,16 @@ export default function TextSizeMenu() { onClick={() => setShowMenu(!showMenu)} className={`group border-none cursor-pointer flex items-center justify-center w-[35px] h-[35px] rounded-full transition-all ${ showMenu - ? "bg-zinc-700 light:bg-slate-200" - : "hover:bg-zinc-700 light:hover:bg-slate-200" + ? "bg-zinc-700 light:bg-divine-pleasure" + : "hover:bg-zinc-700 light:hover:bg-divine-pleasure" }`} > @@ -76,7 +76,7 @@ export default function TextSizeMenu() { ref={menuRef} className="absolute right-0 top-[42px] bg-zinc-800 light:bg-white border border-zinc-700 light:border-slate-300 rounded-lg p-3 w-[200px] flex flex-col gap-1 shadow-lg" > -

+

{t("chat_window.text_size_label")}

{TEXT_SIZES.map(({ key, label, textClass }) => ( @@ -85,7 +85,7 @@ export default function TextSizeMenu() { onClick={() => handleTextSizeChange(key)} className={`flex items-center px-2 py-1 rounded cursor-pointer ${ selectedSize === key - ? "bg-zinc-700 light:bg-slate-200" + ? "bg-zinc-700 light:bg-divine-pleasure" : "hover:bg-zinc-700/50 light:hover:bg-slate-100" }`} > diff --git a/frontend/src/components/WorkspaceChat/ChatContainer/WorkspaceModelPicker/index.jsx b/frontend/src/components/WorkspaceChat/ChatContainer/WorkspaceModelPicker/index.jsx index c121ac0c263..07b01ae0604 100644 --- a/frontend/src/components/WorkspaceChat/ChatContainer/WorkspaceModelPicker/index.jsx +++ b/frontend/src/components/WorkspaceChat/ChatContainer/WorkspaceModelPicker/index.jsx @@ -11,7 +11,6 @@ import { } from "../PromptInput/LLMSelector/action"; import Workspace from "@/models/workspace"; import System from "@/models/system"; -import { SIDEBAR_TOGGLE_EVENT } from "@/components/Sidebar/SidebarToggle"; function fetchModelName(slug, setModelName) { if (!slug) return; @@ -37,15 +36,6 @@ export default function WorkspaceModelPicker({ workspaceSlug = null }) { } = useModal(); const [config, setConfig] = useState({ settings: {}, provider: null }); const [refreshKey, setRefreshKey] = useState(0); - const [sidebarOpen, setSidebarOpen] = useState( - () => window.localStorage.getItem("anythingllm_sidebar_toggle") !== "closed" - ); - - useEffect(() => { - const handleToggle = (e) => setSidebarOpen(e.detail.open); - window.addEventListener(SIDEBAR_TOGGLE_EVENT, handleToggle); - return () => window.removeEventListener(SIDEBAR_TOGGLE_EVENT, handleToggle); - }, []); // Fetch current model name for display useEffect(() => fetchModelName(slug, setModelName), [slug]); @@ -85,25 +75,21 @@ export default function WorkspaceModelPicker({ workspaceSlug = null }) { onClick={() => setShowSelector(false)} /> )} -
+
); diff --git a/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx index 05a95c0b69a..77a1f0fda4c 100644 --- a/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx +++ b/frontend/src/pages/Admin/SystemPromptVariables/VariableRow/index.jsx @@ -43,7 +43,7 @@ export default function VariableRow({ variable, onRefresh }) { case "system": return { bg: "bg-blue-600/20", - text: "text-blue-400 light:text-blue-800", + text: "text-blue-400 light:text-infinite-night", }; case "user": return { diff --git a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx index 31ce51a1b1c..5d0c38c8876 100644 --- a/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx +++ b/frontend/src/pages/Admin/Workspaces/WorkspaceRow/index.jsx @@ -2,13 +2,14 @@ import { useRef } from "react"; import Admin from "@/models/admin"; import paths from "@/utils/paths"; import { LinkSimple, Trash } from "@phosphor-icons/react"; +import { getWorkspaceDisplayName } from "@/utils/workspaceDisplay"; export default function WorkspaceRow({ workspace, users: _users }) { const rowRef = useRef(null); const handleDelete = async () => { if ( !window.confirm( - `Are you sure you want to delete ${workspace.name}?\nAfter you do this it will be unavailable in this instance of AnythingLLM.\n\nThis action is irreversible.` + `Are you sure you want to delete ${getWorkspaceDisplayName(workspace)}?\nAfter you do this it will be unavailable in this instance of AnythingLLM.\n\nThis action is irreversible.` ) ) return false; @@ -23,7 +24,7 @@ export default function WorkspaceRow({ workspace, users: _users }) { className="bg-transparent text-white text-opacity-80 text-xs font-medium border-b border-white/10 h-10" > - {workspace.name} + {getWorkspaceDisplayName(workspace)} - {chat.embed_config.workspace.name} + {getWorkspaceDisplayName(chat.embed_config.workspace)} - {embed.workspace.name} + {getWorkspaceDisplayName(embed.workspace)} diff --git a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx index b63946ccc94..95b6f88297f 100644 --- a/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx +++ b/frontend/src/pages/GeneralSettings/ChatEmbedWidgets/EmbedConfigs/NewEmbedModal/index.jsx @@ -4,6 +4,7 @@ import Workspace from "@/models/workspace"; import { TagsInput } from "react-tag-input-component"; import Embed from "@/models/embed"; import Toggle from "@/components/lib/Toggle"; +import { getWorkspaceDisplayName } from "@/utils/workspaceDisplay"; export function enforceSubmissionSchema(form) { const data = {}; @@ -162,7 +163,7 @@ export const WorkspaceSelection = ({ defaultValue = null }) => { selected={defaultValue === workspace.id} value={workspace.id} > - {workspace.name} + {getWorkspaceDisplayName(workspace)} ); })} diff --git a/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx b/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx index 91054cd000b..2030542a131 100644 --- a/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx +++ b/frontend/src/pages/GeneralSettings/Chats/ChatRow/index.jsx @@ -5,6 +5,7 @@ import ModalWrapper from "@/components/ModalWrapper"; import { useModal } from "@/hooks/useModal"; import MarkdownRenderer from "../MarkdownRenderer"; import { safeJsonParse } from "@/utils/request"; +import { getWorkspaceDisplayName } from "@/utils/workspaceDisplay"; export default function ChatRow({ chat, onDelete }) { const { @@ -38,7 +39,7 @@ export default function ChatRow({ chat, onDelete }) { {chat.user?.username} - {chat.workspace?.name} + {getWorkspaceDisplayName(chat.workspace)} Agent skills unlock new capabilities for your AnythingLLM workspace via{" "} - + @agent {" "} skills that can do specific tasks when invoked. @@ -150,7 +150,7 @@ function FileReview({ item }) {
-

- {chatMode === "chat" ? ( - <> - {t("chat.mode.chat.title")}{" "} - {t("chat.mode.chat.desc-start")}{" "} - {t("chat.mode.chat.and")}{" "} - {t("chat.mode.chat.desc-end")} - - ) : ( - <> - {t("chat.mode.query.title")}{" "} - {t("chat.mode.query.desc-start")}{" "} - {t("chat.mode.query.only")}{" "} - {t("chat.mode.query.desc-end")} - - )} -

+
); } + +function ChatModeDescription({ chatMode }) { + const { t } = useTranslation(); + + if (chatMode === "automatic") { + return ( +

+ {t("chat.mode.automatic.title")}{" "} + {t("chat.mode.automatic.description")} +

+ ); + } + + if (chatMode === "chat") { + return ( +

+ {t("chat.mode.chat.title")} {t("chat.mode.chat.desc-start")}{" "} + {t("chat.mode.chat.and")}{" "} + {t("chat.mode.chat.desc-end")} +

+ ); + } + + return ( +

+ {t("chat.mode.query.title")} {t("chat.mode.query.desc-start")}{" "} + {t("chat.mode.query.only")}{" "} + {t("chat.mode.query.desc-end")} +

+ ); +} diff --git a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx index 0023e33010a..1fc8bad5fe1 100644 --- a/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/GeneralAppearance/DeleteWorkspace/index.jsx @@ -4,6 +4,7 @@ import Workspace from "@/models/workspace"; import paths from "@/utils/paths"; import { useTranslation } from "react-i18next"; import showToast from "@/utils/toast"; +import { getWorkspaceDisplayName } from "@/utils/workspaceDisplay"; export default function DeleteWorkspace({ workspace }) { const { slug } = useParams(); @@ -13,9 +14,9 @@ export default function DeleteWorkspace({ workspace }) { const deleteWorkspace = async () => { if ( !window.confirm( - `${t("general.delete.confirm-start")} ${workspace.name} ${t( - "general.delete.confirm-end" - )}` + `${t("general.delete.confirm-start")} ${getWorkspaceDisplayName( + workspace + )} ${t("general.delete.confirm-end")}` ) ) return false; diff --git a/frontend/src/pages/WorkspaceSettings/index.jsx b/frontend/src/pages/WorkspaceSettings/index.jsx index a41a64f2d6e..60a2a017650 100644 --- a/frontend/src/pages/WorkspaceSettings/index.jsx +++ b/frontend/src/pages/WorkspaceSettings/index.jsx @@ -76,7 +76,7 @@ function ShowWorkspaceChat() { const TabContent = TABS[tab]; return ( -
+
{!isMobile && }
/dev/null || true +fi + +# ── Persist collector hotdir on /workspace ──────────────────────────────────── +# The server and collector reference different hotdir paths; both must resolve +# to the same persistent location on RunPod. +mkdir -p /workspace/hotdir /collector /app/collector +rm -rf /collector/hotdir /app/collector/hotdir +ln -s /workspace/hotdir /collector/hotdir +ln -s /workspace/hotdir /app/collector/hotdir +chown -R anythingllm:anythingllm /collector /app/collector /workspace/hotdir 2>/dev/null || true +echo "[entrypoint] collector hotdirs -> /workspace/hotdir" + +# ── Persist SQLite DB on /workspace ────────────────────────────────────────── +DB_CONTAINER="/app/server/storage/anythingllm.db" +DB_VOLUME="/workspace/anythingllm.db" + +# Remove dangling symlink if present +if [ -L "$DB_CONTAINER" ] && [ ! -e "$DB_CONTAINER" ]; then + rm "$DB_CONTAINER" +fi + +if [ -f "$DB_VOLUME" ] && [ ! -L "$DB_CONTAINER" ]; then + # Volume has a saved DB — replace container copy with symlink + rm -f "$DB_CONTAINER" + ln -s "$DB_VOLUME" "$DB_CONTAINER" + echo "[entrypoint] Restored DB from volume" +elif [ ! -f "$DB_VOLUME" ] && [ ! -L "$DB_CONTAINER" ] && [ -f "$DB_CONTAINER" ]; then + # First boot — move container DB to volume and symlink + mv "$DB_CONTAINER" "$DB_VOLUME" + ln -s "$DB_VOLUME" "$DB_CONTAINER" + echo "[entrypoint] Moved DB to volume and linked" +elif [ ! -L "$DB_CONTAINER" ]; then + # No DB anywhere yet — symlink (Prisma will create it on volume) + ln -s "$DB_VOLUME" "$DB_CONTAINER" + echo "[entrypoint] Pre-linked DB to volume" +else + echo "[entrypoint] DB symlink already in place" +fi + +exec /usr/bin/supervisord -n -c /etc/supervisor/supervisord.conf diff --git a/ramsli-custom/full-source.Dockerfile b/ramsli-custom/full-source.Dockerfile new file mode 100644 index 00000000000..cb224f5e645 --- /dev/null +++ b/ramsli-custom/full-source.Dockerfile @@ -0,0 +1,89 @@ +FROM ubuntu:noble-20251013 AS base + +ARG ARG_UID=1000 +ARG ARG_GID=1000 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN DEBIAN_FRONTEND=noninteractive apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -yq --no-install-recommends \ + unzip curl gnupg libgfortran5 libgbm1 tzdata netcat-openbsd supervisor \ + libasound2t64 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \ + libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libx11-6 libx11-xcb1 libxcb1 \ + libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \ + libxss1 libxtst6 ca-certificates fonts-liberation libappindicator3-1 libnss3 lsb-release \ + xdg-utils git build-essential ffmpeg && \ + mkdir -p /etc/apt/keyrings && \ + curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && \ + echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_18.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && \ + apt-get install -yq --no-install-recommends nodejs && \ + curl -LO https://github.com/yarnpkg/yarn/releases/download/v1.22.19/yarn_1.22.19_all.deb && \ + dpkg -i yarn_1.22.19_all.deb && \ + rm yarn_1.22.19_all.deb && \ + curl -LsSf https://astral.sh/uv/0.6.10/install.sh | sh && \ + mv /root/.local/bin/uv /usr/local/bin/uv && \ + mv /root/.local/bin/uvx /usr/local/bin/uvx && \ + curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \ + -o /usr/local/bin/cloudflared && \ + chmod +x /usr/local/bin/cloudflared && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* + +RUN (getent passwd "$ARG_UID" && userdel -f "$(getent passwd "$ARG_UID" | cut -d: -f1)") || true && \ + (getent group "$ARG_GID" && groupdel "$(getent group "$ARG_GID" | cut -d: -f1)") || true && \ + groupadd -g "$ARG_GID" anythingllm && \ + useradd -l -u "$ARG_UID" -m -d /app -s /bin/bash -g anythingllm anythingllm && \ + mkdir -p /app/frontend /app/server /app/collector && \ + chown -R anythingllm:anythingllm /app + +COPY ./docker/docker-healthcheck.sh /usr/local/bin/ +COPY --chown=anythingllm:anythingllm ./docker/.env.example /app/server/.env + +RUN chmod +x /usr/local/bin/docker-healthcheck.sh + +USER anythingllm +WORKDIR /app + +FROM --platform=$BUILDPLATFORM node:18-slim AS frontend-build +WORKDIR /app/frontend +COPY ./frontend/package.json ./frontend/yarn.lock ./ +RUN yarn install --network-timeout 100000 && yarn cache clean +COPY ./frontend/ ./ +RUN yarn build + +FROM base AS backend-build +COPY --chown=anythingllm:anythingllm ./server /app/server/ +WORKDIR /app/server +RUN yarn install --production --network-timeout 100000 && yarn cache clean + +COPY --chown=anythingllm:anythingllm ./collector /app/collector/ +WORKDIR /app/collector +ENV PUPPETEER_DOWNLOAD_BASE_URL=https://storage.googleapis.com/chrome-for-testing-public +RUN yarn install --production --network-timeout 100000 && yarn cache clean + +FROM base AS production-build +WORKDIR /app +USER root + +COPY --chown=anythingllm:anythingllm --from=frontend-build /app/frontend/dist /app/server/public +COPY --chown=anythingllm:anythingllm --from=backend-build /app/server /app/server +COPY --chown=anythingllm:anythingllm --from=backend-build /app/collector /app/collector + +COPY ramsli-custom/supervisord.conf /etc/supervisor/conf.d/lovora.conf +COPY ramsli-custom/entrypoint.sh /usr/local/bin/lovora-entrypoint.sh + +RUN mkdir -p /var/log/supervisor /collector /collector/hotdir && \ + chmod 755 /var/log/supervisor && \ + chmod +x /usr/local/bin/lovora-entrypoint.sh && \ + chown -R anythingllm:anythingllm /collector /app/collector + +ENV NODE_ENV=production +ENV ANYTHING_LLM_RUNTIME=docker + +HEALTHCHECK --interval=1m --timeout=10s --start-period=1m \ + CMD /bin/bash /usr/local/bin/docker-healthcheck.sh || exit 1 + +EXPOSE 3001 + +ENTRYPOINT ["/usr/local/bin/lovora-entrypoint.sh"] diff --git a/ramsli-custom/supervisord.conf b/ramsli-custom/supervisord.conf new file mode 100644 index 00000000000..7243da087b6 --- /dev/null +++ b/ramsli-custom/supervisord.conf @@ -0,0 +1,30 @@ +[supervisord] +nodaemon=true +user=root +logfile=/var/log/supervisor/supervisord.log +pidfile=/var/run/supervisord.pid + +[program:anythingllm] +command=/bin/bash -c "cd /app/server && export CHECKPOINT_DISABLE=1 && npx prisma generate --schema=./prisma/schema.prisma && npx prisma migrate deploy --schema=./prisma/schema.prisma && exec node /app/server/index.js" +autostart=true +autorestart=true +user=anythingllm +stderr_logfile=/var/log/supervisor/anythingllm.err.log +stdout_logfile=/var/log/supervisor/anythingllm.out.log +environment=HOME="/home/anythingllm",USER="anythingllm" + +[program:collector] +command=/bin/bash -c "exec node /app/collector/index.js" +autostart=true +autorestart=true +user=anythingllm +stderr_logfile=/var/log/supervisor/collector.err.log +stdout_logfile=/var/log/supervisor/collector.out.log +environment=HOME="/home/anythingllm",USER="anythingllm" + +[program:cloudflared] +command=/bin/bash -c "cloudflared tunnel run --token %(ENV_CLOUDFLARE_TOKEN)s" +autostart=true +autorestart=true +stderr_logfile=/var/log/supervisor/cloudflared.err.log +stdout_logfile=/var/log/supervisor/cloudflared.out.log diff --git a/scripts/local-dev-setup.sh b/scripts/local-dev-setup.sh new file mode 100755 index 00000000000..067d0275e51 --- /dev/null +++ b/scripts/local-dev-setup.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_URL="${LOCAL_DEV_REPO_URL:-git@github.com:AndreasRamsli/lovora.git}" +CLONE_DIR="" +FORCE_INSTALL="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --clone-dir) + CLONE_DIR="${2:-}" + shift 2 + ;; + --repo-url) + REPO_URL="${2:-}" + shift 2 + ;; + --force-install) + FORCE_INSTALL="true" + shift + ;; + *) + echo "Unknown option: $1" >&2 + echo "Usage: $0 [--clone-dir /path/to/checkout] [--repo-url git@github.com:AndreasRamsli/lovora.git] [--force-install]" >&2 + exit 1 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CURRENT_REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +TARGET_REPO_DIR="${CURRENT_REPO_DIR}" + +if [[ -n "${CLONE_DIR}" ]]; then + TARGET_REPO_DIR="$(cd "$(dirname "${CLONE_DIR}")" && pwd)/$(basename "${CLONE_DIR}")" + if [[ ! -d "${TARGET_REPO_DIR}/.git" ]]; then + mkdir -p "$(dirname "${TARGET_REPO_DIR}")" + git clone "${REPO_URL}" "${TARGET_REPO_DIR}" + fi +fi + +cd "${TARGET_REPO_DIR}" + +for cmd in git node yarn; do + if [[ "${cmd}" == "yarn" ]]; then + continue + fi + if ! command -v "${cmd}" >/dev/null 2>&1; then + echo "Missing required command: ${cmd}" >&2 + exit 1 + fi +done + +run_yarn() { + if command -v yarn >/dev/null 2>&1; then + yarn "$@" + return + fi + + if command -v corepack >/dev/null 2>&1; then + corepack yarn "$@" + return + fi + + echo "Missing required command: yarn (or corepack)" >&2 + exit 1 +} + +ensure_install() { + local dir="$1" + if [[ "${FORCE_INSTALL}" == "true" || ! -d "${dir}/node_modules" ]]; then + echo "[local-dev-setup] Installing dependencies in ${dir}" + (cd "${dir}" && run_yarn install --network-timeout 100000) + else + echo "[local-dev-setup] Dependencies already installed in ${dir}" + fi +} + +ensure_file() { + local src="$1" + local dest="$2" + if [[ ! -f "${dest}" ]]; then + cp "${src}" "${dest}" + fi +} + +upsert_env() { + local file="$1" + local key="$2" + local value="$3" + + if grep -q "^${key}=" "${file}" 2>/dev/null; then + perl -0pi -e "s|^${key}=.*$|${key}=${value}|m" "${file}" + else + printf "\n%s=%s\n" "${key}" "${value}" >> "${file}" + fi +} + +set_env_default() { + local file="$1" + local key="$2" + local value="$3" + + if ! grep -q "^${key}=" "${file}" 2>/dev/null; then + printf "\n%s=%s\n" "${key}" "${value}" >> "${file}" + fi +} + +generate_secret() { + python - <<'PY' +import secrets +print(secrets.token_urlsafe(32)) +PY +} + +ensure_install "${TARGET_REPO_DIR}" +ensure_install "${TARGET_REPO_DIR}/server" +ensure_install "${TARGET_REPO_DIR}/frontend" +ensure_install "${TARGET_REPO_DIR}/collector" + +ensure_file "${TARGET_REPO_DIR}/server/.env.example" "${TARGET_REPO_DIR}/server/.env.development" +ensure_file "${TARGET_REPO_DIR}/collector/.env.example" "${TARGET_REPO_DIR}/collector/.env.development" +ensure_file "${TARGET_REPO_DIR}/frontend/.env.example" "${TARGET_REPO_DIR}/frontend/.env" + +mkdir -p "${TARGET_REPO_DIR}/.local-storage" +mkdir -p "${TARGET_REPO_DIR}/.local-dev/logs" + +SERVER_ENV="${TARGET_REPO_DIR}/server/.env.development" +COLLECTOR_ENV="${TARGET_REPO_DIR}/collector/.env.development" +FRONTEND_ENV="${TARGET_REPO_DIR}/frontend/.env" +STORAGE_DIR="${TARGET_REPO_DIR}/.local-storage" + +upsert_env "${SERVER_ENV}" "SERVER_PORT" "3001" +upsert_env "${SERVER_ENV}" "COLLECTOR_PORT" "8888" +upsert_env "${SERVER_ENV}" "STORAGE_DIR" "\"${STORAGE_DIR}\"" +set_env_default "${SERVER_ENV}" "JWT_SECRET" "\"$(generate_secret)\"" +set_env_default "${SERVER_ENV}" "SIG_KEY" "\"$(generate_secret)\"" +set_env_default "${SERVER_ENV}" "SIG_SALT" "\"$(generate_secret)\"" +upsert_env "${SERVER_ENV}" "DISABLE_TELEMETRY" "\"true\"" +upsert_env "${SERVER_ENV}" "COLLECTOR_ALLOW_ANY_IP" "\"true\"" +upsert_env "${SERVER_ENV}" "ENABLE_HTTP_LOGGER" "\"true\"" + +upsert_env "${COLLECTOR_ENV}" "STORAGE_DIR" "\"${STORAGE_DIR}\"" +upsert_env "${COLLECTOR_ENV}" "ENABLE_HTTP_LOGGER" "\"true\"" + +upsert_env "${FRONTEND_ENV}" "VITE_API_BASE" "'http://localhost:3001/api'" + +for passthrough in \ + LLM_PROVIDER OPEN_AI_KEY OPEN_MODEL_PREF OPENROUTER_API_KEY OPENROUTER_MODEL_PREF \ + AGENT_SERPER_DEV_KEY AGENT_TAVILY_API_KEY EMBEDDING_ENGINE EMBEDDING_MODEL_PREF \ + GEMINI_API_KEY ANTHROPIC_API_KEY; do + if [[ -n "${!passthrough:-}" ]]; then + upsert_env "${SERVER_ENV}" "${passthrough}" "\"${!passthrough}\"" + fi +done + +echo "[local-dev-setup] Running Prisma generate + migrate deploy" +(cd "${TARGET_REPO_DIR}/server" && npx prisma generate --schema=./prisma/schema.prisma && npx prisma migrate deploy --schema=./prisma/schema.prisma) + +cat </dev/null 2>&1; then + YARN_CMD="yarn" +elif command -v corepack >/dev/null 2>&1; then + YARN_CMD="corepack yarn" +else + echo "Missing required command: yarn (or corepack)" >&2 + exit 1 +fi + +for path in \ + "server/.env.development" \ + "collector/.env.development" \ + "frontend/.env"; do + if [[ ! -f "${path}" ]]; then + echo "Missing ${path}. Run: yarn local:setup" >&2 + exit 1 + fi +done + +if [[ ! -d node_modules ]]; then + echo "Missing root node_modules. Run: yarn local:setup" >&2 + exit 1 +fi + +mkdir -p .local-dev/logs + +exec npx concurrently \ + --names server,frontend,collector \ + --prefix-colors blue,magenta,green \ + --kill-others-on-fail \ + "cd server && ${YARN_CMD} dev" \ + "cd frontend && ${YARN_CMD} dev" \ + "cd collector && ${YARN_CMD} dev" diff --git a/server/endpoints/system.js b/server/endpoints/system.js index cabe09754da..3e5a4c3925a 100644 --- a/server/endpoints/system.js +++ b/server/endpoints/system.js @@ -24,7 +24,6 @@ const { determineLogoFilepath, fetchLogo, validFilename, - renameLogoFile, removeCustomLogo, LOGO_FILENAME, isDefaultFilename, @@ -38,7 +37,11 @@ const { ROLES, isMultiUserSetup, } = require("../utils/middleware/multiUserProtected"); -const { fetchPfp, determinePfpFilepath } = require("../utils/files/pfp"); +const { + fetchPfp, + determinePfpFilepath, + getPfpBasePath, +} = require("../utils/files/pfp"); const { exportChatsAsType } = require("../utils/helpers/chat/convertTo"); const { EventLogs } = require("../models/eventLogs"); const { CollectorApi } = require("../utils/collectorApi"); @@ -774,7 +777,7 @@ function systemEndpoints(app) { const userRecord = await User.get({ id: user.id }); const oldPfpFilename = userRecord.pfpFilename; if (oldPfpFilename) { - const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const storagePath = getPfpBasePath(); const oldPfpPath = path.join( storagePath, normalizePath(userRecord.pfpFilename) @@ -861,7 +864,7 @@ function systemEndpoints(app) { const oldPfpFilename = userRecord.pfpFilename; if (oldPfpFilename) { - const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const storagePath = getPfpBasePath(); const oldPfpPath = path.join( storagePath, normalizePath(oldPfpFilename) @@ -899,6 +902,11 @@ function systemEndpoints(app) { return response.status(400).json({ message: "No logo file provided." }); } + const uploadedFileName = request.randomFileName; + if (!uploadedFileName) { + return response.status(400).json({ message: "File upload failed." }); + } + if (!validFilename(request.file.originalname)) { return response.status(400).json({ message: "Invalid file name. Please choose a different file.", @@ -906,12 +914,11 @@ function systemEndpoints(app) { } try { - const newFilename = await renameLogoFile(request.file.originalname); const existingLogoFilename = await SystemSettings.currentLogoFilename(); await removeCustomLogo(existingLogoFilename); const { success, error } = await SystemSettings._updateSettings({ - logo_filename: newFilename, + logo_filename: uploadedFileName, }); return response.status(success ? 200 : 500).json({ diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index e6d77a0ec8f..2dfe7ebfb18 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -29,6 +29,7 @@ const { CollectorApi } = require("../utils/collectorApi"); const { determineWorkspacePfpFilepath, fetchPfp, + getPfpBasePath, } = require("../utils/files/pfp"); const { getTTSProvider } = require("../utils/TextToSpeech"); const { WorkspaceThread } = require("../models/workspaceThread"); @@ -42,6 +43,24 @@ const { workspaceParsedFilesEndpoints } = require("./workspacesParsedFiles"); function workspaceEndpoints(app) { if (!app) return; const responseCache = new Map(); + const defaultWorkspaceChatClause = ({ + response, + workspaceId, + chatId, + user, + }) => + multiUserMode(response) + ? { + id: Number(chatId), + workspaceId, + user_id: user?.id ?? null, + thread_id: null, + } + : { + id: Number(chatId), + workspaceId, + thread_id: null, + }; app.post( "/workspace/new", @@ -595,12 +614,20 @@ function workspaceEndpoints(app) { async function (request, response) { try { const { chatId } = request.params; + const user = await userFromSession(request, response); const workspace = response.locals.workspace; - const cacheKey = `${workspace.slug}:${chatId}`; + const cacheKey = multiUserMode(response) + ? `${workspace.slug}:${user?.id ?? "anon"}:${chatId}` + : `${workspace.slug}:${chatId}`; const wsChat = await WorkspaceChats.get({ - id: Number(chatId), - workspaceId: workspace.id, + ...defaultWorkspaceChatClause({ + response, + workspaceId: workspace.id, + chatId, + user, + }), }); + if (!wsChat) return response.sendStatus(404).end(); const cachedResponse = responseCache.get(cacheKey); if (cachedResponse) { @@ -695,7 +722,7 @@ function workspaceEndpoints(app) { const oldPfpFilename = workspaceRecord.pfpFilename; if (oldPfpFilename) { - const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const storagePath = getPfpBasePath(); const oldPfpPath = path.join( storagePath, normalizePath(workspaceRecord.pfpFilename) @@ -736,7 +763,7 @@ function workspaceEndpoints(app) { const oldPfpFilename = workspaceRecord.pfpFilename; if (oldPfpFilename) { - const storagePath = path.join(__dirname, "../storage/assets/pfp"); + const storagePath = getPfpBasePath(); const oldPfpPath = path.join( storagePath, normalizePath(oldPfpFilename) diff --git a/server/endpoints/workspacesParsedFiles.js b/server/endpoints/workspacesParsedFiles.js index 44b4a00427e..2af03eb60a9 100644 --- a/server/endpoints/workspacesParsedFiles.js +++ b/server/endpoints/workspacesParsedFiles.js @@ -122,6 +122,67 @@ function workspaceParsedFilesEndpoints(app) { } ); + app.post( + "/workspace/:slug/citation-source", + [validatedRequest, flexUserRoleValid([ROLES.all]), validWorkspaceSlug], + async function (request, response) { + try { + const user = await userFromSession(request, response); + const workspace = response.locals.workspace; + const { + title = null, + published = null, + chunkSource = null, + location = null, + threadSlug = null, + } = reqBody(request); + + const thread = threadSlug + ? await WorkspaceThread.get({ + slug: String(threadSlug), + workspace_id: workspace.id, + user_id: user?.id || null, + }) + : null; + + if (threadSlug && !thread) { + return response.status(200).json({ + title, + pageContent: null, + metadata: null, + location, + mode: "unavailable", + }); + } + + const resolvedSource = await WorkspaceParsedFiles.resolveSourceDocument( + { + workspace, + thread, + user: multiUserMode(response) ? user : null, + sourceIdentity: { + title, + published, + chunkSource, + location, + }, + } + ); + + return response.status(200).json(resolvedSource); + } catch (e) { + console.error(e.message, e); + return response.status(200).json({ + title: null, + pageContent: null, + metadata: null, + location: null, + mode: "unavailable", + }); + } + } + ); + app.post( "/workspace/:slug/parse", [ diff --git a/server/models/documents.js b/server/models/documents.js index a13c3a6a2a5..9f6ae389bc4 100644 --- a/server/models/documents.js +++ b/server/models/documents.js @@ -6,6 +6,15 @@ const { EventLogs } = require("./eventLogs"); const { safeJsonParse } = require("../utils/http"); const { getModelTag } = require("../endpoints/utils"); +function normalizeSourceIdentity(sourceIdentity = {}) { + return { + title: sourceIdentity?.title || null, + published: sourceIdentity?.published || null, + chunkSource: sourceIdentity?.chunkSource || null, + location: sourceIdentity?.location || null, + }; +} + const Document = { writable: ["pinned", "watched", "lastUpdatedAt"], /** @@ -240,6 +249,100 @@ const Document = { const data = await fileData(docPath); return { title: data.title, content: data.pageContent }; }, + resolveSourceDocument: async function (workspace, sourceIdentity = {}) { + const identity = normalizeSourceIdentity(sourceIdentity); + if (!workspace?.id) { + return { + title: identity.title, + pageContent: null, + metadata: null, + location: identity.location, + mode: "unavailable", + }; + } + + const buildResponse = async (docpath, metadata = null) => { + const { fileData } = require("../utils/files"); + const data = await fileData(docpath); + if (!data?.pageContent) { + return { + title: identity.title, + pageContent: null, + metadata: null, + location: identity.location || docpath, + mode: "unavailable", + }; + } + + const { pageContent, ...fileMetadata } = data; + return { + title: fileMetadata.title || identity.title, + pageContent, + metadata: metadata || fileMetadata, + location: fileMetadata.location || docpath, + mode: "full", + }; + }; + + if (identity.location) { + const directMatch = await this.get({ + workspaceId: workspace.id, + docpath: identity.location, + }); + if (directMatch) { + const directMetadata = safeJsonParse(directMatch.metadata, {}); + return await buildResponse(directMatch.docpath, directMetadata); + } + } + + if (!identity.title || !identity.published) { + return { + title: identity.title, + pageContent: null, + metadata: null, + location: identity.location, + mode: "unavailable", + }; + } + + const candidates = ( + await this.where({ workspaceId: workspace.id }, null, null, null, { + id: true, + docpath: true, + metadata: true, + }) + ) + .map((document) => ({ + ...document, + parsedMetadata: safeJsonParse(document.metadata, {}), + })) + .filter( + ({ parsedMetadata }) => + parsedMetadata?.title === identity.title && + parsedMetadata?.published === identity.published + ); + + const narrowedCandidates = + candidates.length > 1 && identity.chunkSource + ? candidates.filter( + ({ parsedMetadata }) => + parsedMetadata?.chunkSource === identity.chunkSource + ) + : candidates; + + if (narrowedCandidates.length !== 1) { + return { + title: identity.title, + pageContent: null, + metadata: null, + location: identity.location, + mode: "unavailable", + }; + } + + const [{ docpath, parsedMetadata }] = narrowedCandidates; + return await buildResponse(docpath, parsedMetadata); + }, // Some data sources have encoded params in them we don't want to log - so strip those details. _stripSource: function (sourceString, type) { diff --git a/server/models/workspace.js b/server/models/workspace.js index 219030df954..c8debcc3838 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -33,6 +33,7 @@ function isNullOrNaN(value) { */ const Workspace = { + VALID_CHAT_MODES: ["chat", "query", "automatic"], defaultPrompt: SystemSettings.saneDefaultSystemPrompt, // Used for generic updates so we can validate keys in request body @@ -93,7 +94,7 @@ const Workspace = { return n; }, chatMode: (value) => { - if (!value || !["chat", "query"].includes(value)) return "chat"; + if (!value || !Workspace.VALID_CHAT_MODES.includes(value)) return "chat"; return value; }, chatProvider: (value) => { @@ -609,6 +610,55 @@ const Workspace = { return false; } }, + + /** + * Checks if the workspace's configured provider/model can use native tool calling. + * Falls back to the workspace chat model or system defaults when agent settings are unset. + * @param {Workspace} workspace + * @returns {Promise} + */ + supportsNativeToolCalling: async function (workspace = {}) { + if (!workspace) return false; + + try { + const { getBaseLLMProviderModel } = require("../utils/helpers"); + const AIbitat = require("../utils/agents/aibitat"); + const provider = + workspace?.agentProvider ?? + workspace?.chatProvider ?? + process.env.LLM_PROVIDER; + if (!provider) return false; + + const model = + workspace?.agentModel ?? + workspace?.chatModel ?? + getBaseLLMProviderModel({ provider }); + + const agentProvider = new AIbitat({ + provider, + model, + }).getProviderForConfig({ provider, model }); + + return (await agentProvider.supportsNativeToolCalling?.()) === true; + } catch (error) { + console.error( + "Failed to determine native tool calling support:", + error.message + ); + return false; + } + }, + + /** + * Returns whether the legacy @agent command affordance is needed for a workspace. + * Automatic mode hides that need when native tool calling is available. + * @param {Workspace} workspace + * @returns {Promise} + */ + isAgentCommandAvailable: async function (workspace = {}) { + if (workspace?.chatMode !== "automatic") return true; + return !(await this.supportsNativeToolCalling(workspace)); + }, }; module.exports = { Workspace }; diff --git a/server/models/workspaceParsedFiles.js b/server/models/workspaceParsedFiles.js index ba692fce1ab..ed3aca09594 100644 --- a/server/models/workspaceParsedFiles.js +++ b/server/models/workspaceParsedFiles.js @@ -6,6 +6,16 @@ const { safeJsonParse } = require("../utils/http"); const fs = require("fs"); const path = require("path"); +function buildUnavailableSource(identity = {}) { + return { + title: identity?.title || null, + pageContent: null, + metadata: null, + location: identity?.location || null, + mode: "unavailable", + }; +} + const WorkspaceParsedFiles = { create: async function ({ filename, @@ -241,6 +251,92 @@ const WorkspaceParsedFiles = { return []; } }, + resolveSourceDocument: async function ({ + workspace, + thread = null, + user = null, + sourceIdentity = {}, + } = {}) { + try { + if (!workspace?.id) return buildUnavailableSource(sourceIdentity); + + const scopedClause = { + workspaceId: workspace.id, + threadId: thread?.id || null, + ...(user ? { userId: user.id } : {}), + }; + + const readParsedFile = (location, metadata = {}) => { + const sourceFile = path.join( + directUploadsPath, + path.basename(location) + ); + if (!fs.existsSync(sourceFile)) + return buildUnavailableSource(sourceIdentity); + + const content = fs.readFileSync(sourceFile, "utf-8"); + const data = safeJsonParse(content, null); + if (!data?.pageContent) return buildUnavailableSource(sourceIdentity); + + const { pageContent, ...fileMetadata } = data; + return { + title: fileMetadata.title || sourceIdentity.title, + pageContent, + metadata: Object.keys(metadata).length ? metadata : fileMetadata, + location: fileMetadata.location || location, + mode: "full", + }; + }; + + if (sourceIdentity?.location) { + const byLocation = await this.get({ + ...scopedClause, + metadata: { contains: sourceIdentity.location }, + }); + + if (byLocation) { + const metadata = safeJsonParse(byLocation.metadata, {}); + if (metadata?.location === sourceIdentity.location) { + return readParsedFile(metadata.location, metadata); + } + } + } + + const scopedFiles = await this.where(scopedClause, null, null, { + id: true, + metadata: true, + }); + + const candidates = scopedFiles + .map((file) => ({ + ...file, + parsedMetadata: safeJsonParse(file.metadata, {}), + })) + .filter( + ({ parsedMetadata }) => + parsedMetadata?.title === sourceIdentity?.title && + parsedMetadata?.published === sourceIdentity?.published + ); + + const narrowedCandidates = + candidates.length > 1 && sourceIdentity?.chunkSource + ? candidates.filter( + ({ parsedMetadata }) => + parsedMetadata?.chunkSource === sourceIdentity.chunkSource + ) + : candidates; + + if (narrowedCandidates.length === 1) { + const [{ parsedMetadata }] = narrowedCandidates; + return readParsedFile(parsedMetadata.location, parsedMetadata); + } + + return await Document.resolveSourceDocument(workspace, sourceIdentity); + } catch (error) { + console.error("Failed to resolve source document:", error); + return buildUnavailableSource(sourceIdentity); + } + }, }; module.exports = { WorkspaceParsedFiles }; diff --git a/server/utils/AiProviders/openRouter/index.js b/server/utils/AiProviders/openRouter/index.js index 28d4306bdae..c6e31fdad0e 100644 --- a/server/utils/AiProviders/openRouter/index.js +++ b/server/utils/AiProviders/openRouter/index.js @@ -238,26 +238,25 @@ class OpenRouterLLM { ]; } - async getChatCompletion(messages = null, { temperature = 0.7, user = null }) { + async getChatCompletion(messages = null, { temperature = 0.7 }) { if (!(await this.isValidChatCompletionModel(this.model))) throw new Error( `OpenRouter chat: ${this.model} is not valid for chat completion!` ); + const requestBody = { + model: this.model, + messages, + temperature, + // This is an OpenRouter specific option that allows us to get the reasoning text + // before the token text. + include_reasoning: true, + }; + const result = await LLMPerformanceMonitor.measureAsyncFunction( - this.openai.chat.completions - .create({ - model: this.model, - messages, - temperature, - // This is an OpenRouter specific option that allows us to get the reasoning text - // before the token text. - include_reasoning: true, - user: user?.id ? `user_${user.id}` : "", - }) - .catch((e) => { - throw new Error(e.message); - }) + this.openai.chat.completions.create(requestBody).catch((e) => { + throw new Error(e.message); + }) ); if ( @@ -283,26 +282,24 @@ class OpenRouterLLM { }; } - async streamGetChatCompletion( - messages = null, - { temperature = 0.7, user = null } - ) { + async streamGetChatCompletion(messages = null, { temperature = 0.7 }) { if (!(await this.isValidChatCompletionModel(this.model))) throw new Error( `OpenRouter chat: ${this.model} is not valid for chat completion!` ); + const requestBody = { + model: this.model, + stream: true, + messages, + temperature, + // This is an OpenRouter specific option that allows us to get the reasoning text + // before the token text. + include_reasoning: true, + }; + const measuredStreamRequest = await LLMPerformanceMonitor.measureStream({ - func: this.openai.chat.completions.create({ - model: this.model, - stream: true, - messages, - temperature, - // This is an OpenRouter specific option that allows us to get the reasoning text - // before the token text. - include_reasoning: true, - user: user?.id ? `user_${user.id}` : "", - }), + func: this.openai.chat.completions.create(requestBody), messages, // OpenRouter returns the usage in the stream as the very last chunk **after** the finish reason. // so we don't need to run the prompt token calculation. diff --git a/server/utils/agents/aibitat/index.js b/server/utils/agents/aibitat/index.js index 8fa28aeacd6..0ecc43b99e0 100644 --- a/server/utils/agents/aibitat/index.js +++ b/server/utils/agents/aibitat/index.js @@ -606,10 +606,22 @@ ${this.getHistory({ to: route.to }) } // This is normal chat between user<->agent - return this.getHistory(route).map((c) => ({ - content: c.content, - role: c.from === route.to ? "user" : "assistant", - })); + return this.getHistory(route).map((c) => { + const message = { + content: c.content, + role: c.from === route.to ? "user" : "assistant", + }; + + if ( + c.attachments && + c.attachments.length > 0 && + message.role === "user" + ) { + message.attachments = c.attachments; + } + + return message; + }); } /** @@ -626,6 +638,21 @@ ${this.getHistory({ to: route.to }) async reply(route) { const fromConfig = this.getAgentConfig(route.from); const chatHistory = this.getOrFormatNodeChatHistory(route); + if (typeof this.fetchParsedFileContext === "function") { + const parsedFileContext = await this.fetchParsedFileContext(); + if (parsedFileContext) { + for (let i = chatHistory.length - 1; i >= 0; i--) { + if (chatHistory[i].role === "user") { + chatHistory[i] = { + ...chatHistory[i], + content: chatHistory[i].content + parsedFileContext, + }; + break; + } + } + } + } + const messages = [ { content: fromConfig.role, @@ -674,6 +701,17 @@ ${this.getHistory({ to: route.to }) return content; } + async #safeProviderCall(providerCall) { + try { + return await providerCall(); + } catch (error) { + console.error(`[AIbitat] Provider error: ${error.message}`, { + hide_meta: true, + }); + throw new APIError(`The agent model failed to respond: ${error.message}`); + } + } + /** * Handle the async (streaming) execution of the provider * with tool calls. @@ -697,10 +735,8 @@ ${this.getHistory({ to: route.to }) }; /** @type {{ functionCall: { name: string, arguments: string }, textResponse: string, uuid: string }} */ - const completionStream = await provider.stream( - messages, - functions, - eventHandler + const completionStream = await this.#safeProviderCall(() => + provider.stream(messages, functions, eventHandler) ); if (completionStream.functionCall) { @@ -712,7 +748,9 @@ ${this.getHistory({ to: route.to }) `Maximum tool call limit (${this.maxToolCalls}) reached. Generating a final response from what I have so far.` ); - const finalStream = await provider.stream(messages, [], eventHandler); + const finalStream = await this.#safeProviderCall(() => + provider.stream(messages, [], eventHandler) + ); const finalUuid = finalStream?.uuid || v4(); eventHandler?.("reportStreamEvent", { type: "usageMetrics", @@ -847,7 +885,9 @@ ${this.getHistory({ to: route.to }) }; // get the chat completion - const completion = await provider.complete(messages, functions); + const completion = await this.#safeProviderCall(() => + provider.complete(messages, functions) + ); if (completion.functionCall) { if (depth >= this.maxToolCalls) { @@ -858,7 +898,9 @@ ${this.getHistory({ to: route.to }) `Maximum tool call limit (${this.maxToolCalls}) reached. Generating a final response from what I have so far.` ); - const finalCompletion = await provider.complete(messages, []); + const finalCompletion = await this.#safeProviderCall(() => + provider.complete(messages, []) + ); eventHandler?.("reportStreamEvent", { type: "usageMetrics", uuid: msgUUID, @@ -959,9 +1001,10 @@ ${this.getHistory({ to: route.to }) * Provide a feedback where it was interrupted if you want to. * * @param feedback The feedback to the interruption if any. + * @param attachments Optional attachments (images) to include with the feedback. * @returns */ - async continue(feedback) { + async continue(feedback, attachments = []) { const lastChat = this._chats.at(-1); if (!lastChat || lastChat.state !== "interrupt") { throw new Error("No chat to continue"); @@ -981,6 +1024,7 @@ ${this.getHistory({ to: route.to }) from, to, content: feedback, + ...(attachments?.length > 0 ? { attachments } : {}), }; // register the message in the chat history diff --git a/server/utils/agents/aibitat/plugins/chat-history.js b/server/utils/agents/aibitat/plugins/chat-history.js index d2a05be08c8..bac97cd09b5 100644 --- a/server/utils/agents/aibitat/plugins/chat-history.js +++ b/server/utils/agents/aibitat/plugins/chat-history.js @@ -21,6 +21,7 @@ const chatHistory = { // We need a full conversation reply with prev being from // the USER and the last being from anyone other than the user. if (prev.from !== "USER" || last.from === "USER") return; + const attachments = prev.attachments || []; // If we have a post-reply flow we should save the chat using this special flow // so that post save cleanup and other unique properties can be run as opposed to regular chat. @@ -28,6 +29,7 @@ const chatHistory = { await this._storeSpecial(aibitat, { prompt: prev.content, response: last.content, + attachments, options: aibitat._replySpecialAttributes, }); delete aibitat._replySpecialAttributes; @@ -37,11 +39,15 @@ const chatHistory = { await this._store(aibitat, { prompt: prev.content, response: last.content, + attachments, }); } catch {} }); }, - _store: async function (aibitat, { prompt, response } = {}) { + _store: async function ( + aibitat, + { prompt, response, attachments = [] } = {} + ) { const invocation = aibitat.handlerProps.invocation; const metrics = aibitat.provider?.getUsage?.() ?? {}; const citations = aibitat._pendingCitations ?? []; @@ -52,6 +58,7 @@ const chatHistory = { text: response, sources: citations, type: "chat", + attachments, metrics, }, user: { id: invocation?.user_id || null }, @@ -61,7 +68,7 @@ const chatHistory = { }, _storeSpecial: async function ( aibitat, - { prompt, response, options = {} } = {} + { prompt, response, attachments = [], options = {} } = {} ) { const invocation = aibitat.handlerProps.invocation; const metrics = aibitat.provider?.getUsage?.() ?? {}; @@ -78,6 +85,7 @@ const chatHistory = { ? options.storedResponse(response) : response, type: options?.saveAsType ?? "chat", + attachments, metrics, }, user: { id: invocation?.user_id || null }, diff --git a/server/utils/agents/aibitat/plugins/websocket.js b/server/utils/agents/aibitat/plugins/websocket.js index 2544918652b..1cdd07db9bc 100644 --- a/server/utils/agents/aibitat/plugins/websocket.js +++ b/server/utils/agents/aibitat/plugins/websocket.js @@ -96,13 +96,16 @@ const websocket = { }); aibitat.onInterrupt(async (node) => { - const feedback = await socket.askForFeedback(socket, node); + const { feedback, attachments } = await socket.askForFeedback( + socket, + node + ); if (WEBSOCKET_BAIL_COMMANDS.includes(feedback)) { socket.close(); return; } - await aibitat.continue(feedback); + await aibitat.continue(feedback, attachments); }); /** @@ -110,7 +113,7 @@ const websocket = { * * @param socket The content to summarize. // AIbitatWebSocket & { receive: any, echo: any } * @param node The chat node // { from: string; to: string } - * @returns The summarized content. + * @returns {{ feedback: string, attachments: Array }} The feedback and any attachments. */ socket.askForFeedback = (socket, node) => { socket.awaitResponse = (question = "waiting...") => { @@ -123,7 +126,10 @@ const websocket = { if (data.type !== "awaitingFeedback") return; delete socket.handleFeedback; clearTimeout(socketTimeout); - resolve(data.feedback); + resolve({ + feedback: data.feedback, + attachments: data.attachments || [], + }); return; }; @@ -133,7 +139,7 @@ const websocket = { `Client took too long to respond, chat thread is dead after ${SOCKET_TIMEOUT_MS}ms` ) ); - resolve("exit"); + resolve({ feedback: "exit", attachments: [] }); return; }, SOCKET_TIMEOUT_MS); }); diff --git a/server/utils/agents/aibitat/providers/ai-provider.js b/server/utils/agents/aibitat/providers/ai-provider.js index 57e4329a00d..4593fe82353 100644 --- a/server/utils/agents/aibitat/providers/ai-provider.js +++ b/server/utils/agents/aibitat/providers/ai-provider.js @@ -114,6 +114,20 @@ class Provider { return this._client; } + supportsNativeToolCallingViaEnv(providerTag = "") { + if (!("PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING" in process.env)) return false; + if (!providerTag) return false; + return ( + process.env.PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING?.includes( + providerTag + ) || false + ); + } + + supportsNativeToolCalling() { + return false; + } + /** * * @param {string} provider - the string key of the provider LLM being loaded. @@ -439,6 +453,34 @@ class Provider { return false; } + formatMessageWithAttachments(message) { + if (!message.attachments || message.attachments.length === 0) { + return message; + } + + const content = [{ type: "text", text: message.content }]; + for (const attachment of message.attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + + const { attachments: _attachments, ...rest } = message; + return { + ...rest, + content, + }; + } + + formatMessagesWithAttachments(messages = []) { + return messages.map((message) => + this.formatMessageWithAttachments(message) + ); + } + /** * Resets the usage metrics to zero and starts the request timer. * Call this before each completion to ensure accurate per-call metrics. @@ -505,10 +547,11 @@ class Provider { async stream(messages, functions = [], eventHandler = null) { this.providerLog("Provider.stream - will process this chat completion."); const msgUUID = v4(); + const formattedMessages = this.formatMessagesWithAttachments(messages); const stream = await this.client.chat.completions.create({ model: this.model, stream: true, - messages, + messages: formattedMessages, ...(Array.isArray(functions) && functions?.length > 0 ? { functions } : {}), diff --git a/server/utils/agents/aibitat/providers/anthropic.js b/server/utils/agents/aibitat/providers/anthropic.js index 343d96b3ac5..5211908b032 100644 --- a/server/utils/agents/aibitat/providers/anthropic.js +++ b/server/utils/agents/aibitat/providers/anthropic.js @@ -26,6 +26,10 @@ class AnthropicProvider extends Provider { this.model = model; } + supportsNativeToolCalling() { + return true; + } + /** * Parses the cache control ENV variable * @@ -72,6 +76,13 @@ class AnthropicProvider extends Provider { ]; } + #parseDataUrl(dataUrl) { + if (!dataUrl || !dataUrl.startsWith("data:")) return null; + const matches = dataUrl.match(/^data:([^;]+);base64,(.+)$/); + if (!matches) return null; + return { mediaType: matches[1], data: matches[2] }; + } + #prepareMessages(messages = []) { // Extract system prompt and filter out any system messages from the main chat. let systemPrompt = @@ -120,6 +131,22 @@ class AnthropicProvider extends Provider { item.type !== "text" || (item.text && item.text.trim().length > 0) ); + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + const parsed = this.#parseDataUrl(attachment.contentString); + if (parsed) { + content.push({ + type: "image", + source: { + type: "base64", + media_type: parsed.mediaType, + data: parsed.data, + }, + }); + } + } + } + if (content.length === 0) return processedMessages; // Add a text block to assistant messages with tool use if one doesn't exist. @@ -139,7 +166,8 @@ class AnthropicProvider extends Provider { // Merge consecutive messages from the same role. lastMessage.content.push(...content); } else { - processedMessages.push({ ...message, content }); + const { attachments: _attachments, ...restOfMessage } = message; + processedMessages.push({ ...restOfMessage, content }); } return processedMessages; diff --git a/server/utils/agents/aibitat/providers/azure.js b/server/utils/agents/aibitat/providers/azure.js index 35f66aa676c..480d5ed3c76 100644 --- a/server/utils/agents/aibitat/providers/azure.js +++ b/server/utils/agents/aibitat/providers/azure.js @@ -28,6 +28,10 @@ class AzureOpenAiProvider extends Provider { return true; } + supportsNativeToolCalling() { + return true; + } + /** * Stream a chat completion from Azure OpenAI with tool calling. * diff --git a/server/utils/agents/aibitat/providers/bedrock.js b/server/utils/agents/aibitat/providers/bedrock.js index 914d2ebc662..3849015cb3e 100644 --- a/server/utils/agents/aibitat/providers/bedrock.js +++ b/server/utils/agents/aibitat/providers/bedrock.js @@ -57,8 +57,7 @@ class AWSBedrockProvider extends InheritMultiple([Provider, UnTooled]) { */ supportsNativeToolCalling() { if (this._supportsToolCalling !== null) return this._supportsToolCalling; - const supportsToolCalling = - process.env.PROVIDER_SUPPORTS_NATIVE_TOOL_CALLING?.includes("bedrock"); + const supportsToolCalling = this.supportsNativeToolCallingViaEnv("bedrock"); if (supportsToolCalling) this.providerLog("AWS Bedrock native tool calling is ENABLED via ENV."); @@ -95,21 +94,41 @@ class AWSBedrockProvider extends InheritMultiple([Provider, UnTooled]) { // or otherwise absorb headaches that can arise from Ollama models #convertToLangchainPrototypes(chats = []) { const langchainChats = []; - const roleToMessageMap = { - system: SystemMessage, - user: HumanMessage, - assistant: AIMessage, - }; for (const chat of chats) { - if (!roleToMessageMap.hasOwnProperty(chat.role)) continue; - const MessageClass = roleToMessageMap[chat.role]; - langchainChats.push(new MessageClass({ content: chat.content })); + if (chat.role === "system") { + langchainChats.push(new SystemMessage({ content: chat.content })); + } else if (chat.role === "user") { + langchainChats.push( + new HumanMessage({ + content: this.#formatContentWithAttachments(chat), + }) + ); + } else if (chat.role === "assistant") { + langchainChats.push(new AIMessage({ content: chat.content })); + } } return langchainChats; } + #formatContentWithAttachments(chat) { + if (!chat.attachments || chat.attachments.length === 0) { + return chat.content; + } + + const content = [{ type: "text", text: chat.content }]; + for (const attachment of chat.attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + return content; + } + /** * Convert aibitat message history to Langchain message prototypes with * proper tool call / tool result handling for native tool calling. @@ -176,7 +195,11 @@ class AWSBedrockProvider extends InheritMultiple([Provider, UnTooled]) { } else if (chat.role === "system") { langchainChats.push(new SystemMessage({ content: chat.content })); } else if (chat.role === "user") { - langchainChats.push(new HumanMessage({ content: chat.content })); + langchainChats.push( + new HumanMessage({ + content: this.#formatContentWithAttachments(chat), + }) + ); } else if (chat.role === "assistant") { langchainChats.push(new AIMessage({ content: chat.content })); } diff --git a/server/utils/agents/aibitat/providers/gemini.js b/server/utils/agents/aibitat/providers/gemini.js index 5308e37a55e..0b04967b38c 100644 --- a/server/utils/agents/aibitat/providers/gemini.js +++ b/server/utils/agents/aibitat/providers/gemini.js @@ -35,6 +35,10 @@ class GeminiProvider extends Provider { return true; } + supportsNativeToolCalling() { + return this.supportsToolCalling; + } + get supportsAgentStreaming() { // Tool call streaming results in a 400/503 error for all non-gemini models // using the compatible v1beta/openai/ endpoint @@ -141,6 +145,23 @@ class GeminiProvider extends Provider { return; } + if (message.attachments && message.attachments.length > 0) { + const content = [{ type: "text", text: message.content }]; + for (const attachment of message.attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + formattedMessages.push({ + role: message.role, + content, + }); + return; + } + formattedMessages.push({ role: message.role, content: message.content, diff --git a/server/utils/agents/aibitat/providers/helpers/tooled.js b/server/utils/agents/aibitat/providers/helpers/tooled.js index b4e2119a2dd..8a24416e53f 100644 --- a/server/utils/agents/aibitat/providers/helpers/tooled.js +++ b/server/utils/agents/aibitat/providers/helpers/tooled.js @@ -35,6 +35,28 @@ function formatFunctionsToTools(functions) { })); } +function formatMessageWithAttachments(message) { + if (!message.attachments || message.attachments.length === 0) { + return message; + } + + const content = [{ type: "text", text: message.content }]; + for (const attachment of message.attachments) { + content.push({ + type: "image_url", + image_url: { + url: attachment.contentString, + }, + }); + } + + const { attachments: _attachments, ...rest } = message; + return { + ...rest, + content, + }; +} + /** * Convert the aibitat message history (which uses role:"function" with * `originalFunctionCall` metadata) into the OpenAI tool-calling message @@ -112,9 +134,11 @@ function formatMessagesForTools(messages, options = {}) { message.role === "assistant" && !("reasoning_content" in message) ) { - formattedMessages.push({ ...message, reasoning_content: "" }); + formattedMessages.push( + formatMessageWithAttachments({ ...message, reasoning_content: "" }) + ); } else { - formattedMessages.push(message); + formattedMessages.push(formatMessageWithAttachments(message)); } } diff --git a/server/utils/agents/aibitat/providers/helpers/untooled.js b/server/utils/agents/aibitat/providers/helpers/untooled.js index 17134111800..7a48ad2c58e 100644 --- a/server/utils/agents/aibitat/providers/helpers/untooled.js +++ b/server/utils/agents/aibitat/providers/helpers/untooled.js @@ -18,7 +18,7 @@ class UnTooled { `${prevMsg}\n${msg.content}`; return; } - modifiedMessages.push(msg); + modifiedMessages.push(this.formatMessageWithAttachments(msg)); }); return modifiedMessages; } @@ -119,6 +119,10 @@ ${JSON.stringify(def.parameters.properties, null, 4)}\n`; } buildToolCallMessages(history = [], functions = []) { + const formattedHistory = history.map((msg) => + this.formatMessageWithAttachments(msg) + ); + return [ { content: `You are a program which picks the most optimal function and parameters to call. @@ -138,7 +142,7 @@ ${JSON.stringify(def.parameters.properties, null, 4)}\n`; Now pick a function if there is an appropriate one to use given the last user message and the given conversation so far.`, role: "system", }, - ...history, + ...formattedHistory, ]; } diff --git a/server/utils/agents/aibitat/providers/ollama.js b/server/utils/agents/aibitat/providers/ollama.js index edc6255215d..d90eb856417 100644 --- a/server/utils/agents/aibitat/providers/ollama.js +++ b/server/utils/agents/aibitat/providers/ollama.js @@ -91,6 +91,33 @@ class OllamaProvider extends InheritMultiple([Provider, UnTooled]) { }); } + #parseImageDataUrl(dataUrl) { + if (!dataUrl || !dataUrl.startsWith("data:")) return null; + const matches = dataUrl.match(/^data:[^;]+;base64,(.+)$/); + if (!matches) return null; + return matches[1]; + } + + formatMessageWithAttachments(message) { + if (!message.attachments || message.attachments.length === 0) { + return message; + } + + const images = []; + for (const attachment of message.attachments) { + const imageData = this.#parseImageDataUrl(attachment.contentString); + if (imageData) { + images.push(imageData); + } + } + + const { attachments: _attachments, ...restOfMessage } = message; + return { + ...restOfMessage, + ...(images.length > 0 ? { images } : {}), + }; + } + /** * Convert aibitat's internal message history (which uses role:"function" with * originalFunctionCall metadata) into the Ollama tool-calling message format @@ -128,7 +155,22 @@ class OllamaProvider extends InheritMultiple([Provider, UnTooled]) { : JSON.stringify(message.content), }); } else { - formatted.push(message); + if (message.attachments && message.attachments.length > 0) { + const images = []; + for (const attachment of message.attachments) { + const imageData = this.#parseImageDataUrl(attachment.contentString); + if (imageData) { + images.push(imageData); + } + } + const { attachments: _attachments, ...restOfMessage } = message; + formatted.push({ + ...restOfMessage, + ...(images.length > 0 ? { images } : {}), + }); + } else { + formatted.push(message); + } } } return formatted; diff --git a/server/utils/agents/aibitat/providers/openai.js b/server/utils/agents/aibitat/providers/openai.js index f3d39c513da..ee70173e166 100644 --- a/server/utils/agents/aibitat/providers/openai.js +++ b/server/utils/agents/aibitat/providers/openai.js @@ -30,10 +30,15 @@ class OpenAIProvider extends Provider { return true; } + supportsNativeToolCalling() { + return true; + } + /** * Format the messages to the OpenAI API Responses format. * - If the message is our internal `function` type, then we need to map it to a function call + output format * - Otherwise, map it to the input text format for user, system, and assistant messages + * - Handles attachments (images) for multimodal support * * @param {any[]} messages - The messages to format. * @returns {OpenAI.OpenAI.Responses.ResponseInput[]} The formatted messages. @@ -69,14 +74,25 @@ class OpenAIProvider extends Provider { return; } + const content = [ + { + type: message.role === "assistant" ? "output_text" : "input_text", + text: message.content, + }, + ]; + + if (message.attachments && message.attachments.length > 0) { + for (const attachment of message.attachments) { + content.push({ + type: "input_image", + image_url: attachment.contentString, + }); + } + } + formattedMessages.push({ role: message.role, - content: [ - { - type: message.role === "assistant" ? "output_text" : "input_text", - text: message.content, - }, - ], + content, }); }); diff --git a/server/utils/agents/aibitat/providers/openrouter.js b/server/utils/agents/aibitat/providers/openrouter.js index a6a2fa1b2ce..395b6b2275f 100644 --- a/server/utils/agents/aibitat/providers/openrouter.js +++ b/server/utils/agents/aibitat/providers/openrouter.js @@ -70,7 +70,6 @@ class OpenRouterProvider extends InheritMultiple([Provider, UnTooled]) { .create({ model: this.model, messages, - user: this.executingUserId, }) .then((result) => { if (!result.hasOwnProperty("choices")) @@ -89,7 +88,6 @@ class OpenRouterProvider extends InheritMultiple([Provider, UnTooled]) { model: this.model, stream: true, messages, - user: this.executingUserId, }); } diff --git a/server/utils/agents/index.js b/server/utils/agents/index.js index d07604f51df..ff2bd5ebb6c 100644 --- a/server/utils/agents/index.js +++ b/server/utils/agents/index.js @@ -3,6 +3,7 @@ const AgentPlugins = require("./aibitat/plugins"); const { WorkspaceAgentInvocation, } = require("../../models/workspaceAgentInvocation"); +const { WorkspaceParsedFiles } = require("../../models/workspaceParsedFiles"); const { User } = require("../../models/user"); const { WorkspaceChats } = require("../../models/workspaceChats"); const { safeJsonParse } = require("../http"); @@ -10,6 +11,7 @@ const { USER_AGENT, WORKSPACE_AGENT } = require("./defaults"); const ImportedPlugin = require("./imported"); const { AgentFlows } = require("../agentFlows"); const MCPCompatibilityLayer = require("../MCP"); +const { getAndClearInvocationAttachments } = require("../chats/agents"); class AgentHandler { #invocationUUID; @@ -19,6 +21,7 @@ class AgentHandler { channel = null; provider = null; model = null; + attachments = []; constructor({ uuid }) { this.#invocationUUID = uuid; @@ -587,9 +590,53 @@ class AgentHandler { ]; } + async #fetchParsedFileContext() { + try { + const user = this.invocation.user_id + ? { id: this.invocation.user_id } + : null; + const thread = this.invocation.thread_id + ? { id: this.invocation.thread_id } + : null; + const parsedFiles = await WorkspaceParsedFiles.getContextFiles( + this.invocation.workspace, + thread, + user + ); + + if (!parsedFiles || parsedFiles.length === 0) return ""; + + this.log( + `Injecting ${parsedFiles.length} parsed file(s) into the agent turn` + ); + + return ( + "\n\n\n" + + parsedFiles + .map((doc, index) => { + const filename = doc.title || `Document ${index + 1}`; + return `\n${doc.pageContent}\n`; + }) + .join("\n") + + "\n" + ); + } catch (error) { + this.log("Error fetching parsed file context", error.message); + return ""; + } + } + + #stripAgentCommand(message = "") { + const stripped = String(message) + .replace(/^@agent\s*/, "") + .trim(); + return stripped || "Hello!"; + } + async init() { await this.#validInvocation(); this.#providerSetupAndCheck(); + this.attachments = getAndClearInvocationAttachments(this.#invocationUUID); return this; } @@ -607,6 +654,7 @@ class AgentHandler { log: this.log, }, }); + this.aibitat.fetchParsedFileContext = () => this.#fetchParsedFileContext(); // Attach standard websocket plugin for frontend communication. this.log(`Attached ${AgentPlugins.websocket.name} plugin to Agent cluster`); @@ -635,7 +683,8 @@ class AgentHandler { return this.aibitat.start({ from: USER_AGENT.name, to: this.channel ?? WORKSPACE_AGENT.name, - content: this.invocation.prompt, + content: this.#stripAgentCommand(this.invocation.prompt), + ...(this.attachments.length > 0 ? { attachments: this.attachments } : {}), }); } } diff --git a/server/utils/chats/agents.js b/server/utils/chats/agents.js index 26de10e8ace..3ca2eba1256 100644 --- a/server/utils/chats/agents.js +++ b/server/utils/chats/agents.js @@ -2,8 +2,26 @@ const pluralize = require("pluralize"); const { WorkspaceAgentInvocation, } = require("../../models/workspaceAgentInvocation"); +const { Workspace } = require("../../models/workspace"); const { writeResponseChunk } = require("../helpers/chat/responses"); +// This cache only works for a single server process. +// The HTTP stream creates the invocation and the websocket-side agent handler +// consumes the cached attachments immediately after connecting. +const invocationAttachmentsCache = new Map(); + +function cacheInvocationAttachments(uuid, attachments = []) { + if (attachments.length > 0) { + invocationAttachmentsCache.set(uuid, attachments); + } +} + +function getAndClearInvocationAttachments(uuid) { + const attachments = invocationAttachmentsCache.get(uuid) || []; + invocationAttachmentsCache.delete(uuid); + return attachments; +} + async function grepAgents({ uuid, response, @@ -11,9 +29,15 @@ async function grepAgents({ workspace, user = null, thread = null, + attachments = [], }) { + let nativeToolingEnabled = false; + if (workspace?.chatMode === "automatic") { + nativeToolingEnabled = await Workspace.supportsNativeToolCalling(workspace); + } + const agentHandles = WorkspaceAgentInvocation.parseAgents(message); - if (agentHandles.length > 0) { + if (agentHandles.length > 0 || nativeToolingEnabled) { const { invocation: newInvocation } = await WorkspaceAgentInvocation.new({ prompt: message, workspace: workspace, @@ -25,12 +49,12 @@ async function grepAgents({ writeResponseChunk(response, { id: uuid, type: "statusResponse", - textResponse: `${pluralize( - "Agent", - agentHandles.length - )} ${agentHandles.join( - ", " - )} could not be called. Chat will be handled as default chat.`, + textResponse: + agentHandles.length > 0 + ? `${pluralize("Agent", agentHandles.length)} ${agentHandles.join( + ", " + )} could not be called. Chat will be handled as default chat.` + : "Automatic agent mode could not be started. Chat will be handled as default chat.", sources: [], close: true, animate: false, @@ -39,6 +63,8 @@ async function grepAgents({ return; } + cacheInvocationAttachments(newInvocation.uuid, attachments); + writeResponseChunk(response, { id: uuid, type: "agentInitWebsocketConnection", @@ -53,12 +79,12 @@ 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: + agentHandles.length > 0 + ? `${pluralize("Agent", agentHandles.length)} ${agentHandles.join( + ", " + )} invoked.\nSwapping over to agent chat. Type /exit to exit agent execution loop early.` + : "Automatic agent mode invoked.\nSwapping over to agent chat. Type /exit to exit agent execution loop early.", sources: [], close: true, error: null, @@ -70,4 +96,4 @@ async function grepAgents({ return false; } -module.exports = { grepAgents }; +module.exports = { grepAgents, getAndClearInvocationAttachments }; diff --git a/server/utils/chats/stream.js b/server/utils/chats/stream.js index acb1e4a5c8a..7d7dd70041c 100644 --- a/server/utils/chats/stream.js +++ b/server/utils/chats/stream.js @@ -13,7 +13,7 @@ const { sourceIdentifier, } = require("./index"); -const VALID_CHAT_MODE = ["chat", "query"]; +const VALID_CHAT_MODE = ["chat", "query", "automatic"]; async function streamChatWithWorkspace( response, @@ -47,6 +47,7 @@ async function streamChatWithWorkspace( user, workspace, thread, + attachments, }); if (isAgentChat) return; diff --git a/server/utils/files/logo.js b/server/utils/files/logo.js index fa67fbfca56..adefd7e32ff 100644 --- a/server/utils/files/logo.js +++ b/server/utils/files/logo.js @@ -7,6 +7,16 @@ const { normalizePath, isWithin } = require("."); const LOGO_FILENAME = "anything-llm.png"; const LOGO_FILENAME_DARK = "anything-llm-dark.png"; +function getCustomAssetsDirectory() { + return process.env.STORAGE_DIR + ? path.join(process.env.STORAGE_DIR, "assets") + : path.join(__dirname, "../../storage/assets"); +} + +function getBundledAssetsDirectory() { + return path.join(__dirname, "../../storage/assets"); +} + /** * Checks if the filename is the default logo filename for dark or light mode. * @param {string} filename - The filename to check. @@ -32,17 +42,20 @@ function getDefaultFilename(darkMode = true) { async function determineLogoFilepath(defaultFilename = LOGO_FILENAME) { const currentLogoFilename = await SystemSettings.currentLogoFilename(); - const basePath = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets") - : path.join(__dirname, "../../storage/assets"); - const defaultFilepath = path.join(basePath, defaultFilename); + const customAssetsPath = getCustomAssetsDirectory(); + const bundledAssetsPath = getBundledAssetsDirectory(); + const defaultFilepath = fs.existsSync( + path.join(customAssetsPath, defaultFilename) + ) + ? path.join(customAssetsPath, defaultFilename) + : path.join(bundledAssetsPath, defaultFilename); if (currentLogoFilename && validFilename(currentLogoFilename)) { const customLogoPath = path.join( - basePath, + customAssetsPath, normalizePath(currentLogoFilename) ); - if (!isWithin(path.resolve(basePath), path.resolve(customLogoPath))) + if (!isWithin(path.resolve(customAssetsPath), path.resolve(customLogoPath))) return defaultFilepath; return fs.existsSync(customLogoPath) ? customLogoPath : defaultFilepath; } @@ -70,33 +83,40 @@ function fetchLogo(logoPath) { }; } -async function renameLogoFile(originalFilename = null) { - const extname = path.extname(originalFilename) || ".png"; +async function renameLogoFile(uploadedFile = null) { + const assetsDirectory = getCustomAssetsDirectory(); + fs.mkdirSync(assetsDirectory, { recursive: true }); + + const storedFilename = + typeof uploadedFile === "object" + ? uploadedFile?.filename || + (uploadedFile?.path ? path.basename(uploadedFile.path) : null) + : null; + const originalFilename = + typeof uploadedFile === "string" + ? uploadedFile + : uploadedFile?.originalname || storedFilename || null; + + const extname = path.extname(originalFilename || "") || ".png"; const newFilename = `${v4()}${extname}`; - const assetsDirectory = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets") - : path.join(__dirname, `../../storage/assets`); - const originalFilepath = path.join( - assetsDirectory, - normalizePath(originalFilename) - ); + + const originalFilepath = storedFilename + ? path.join(assetsDirectory, normalizePath(storedFilename)) + : path.join(assetsDirectory, normalizePath(originalFilename)); + if (!isWithin(path.resolve(assetsDirectory), path.resolve(originalFilepath))) throw new Error("Invalid file path."); + if (!fs.existsSync(originalFilepath)) + throw new Error("Uploaded logo file could not be found."); - // The output always uses a random filename. - const outputFilepath = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets", normalizePath(newFilename)) - : path.join(__dirname, `../../storage/assets`, normalizePath(newFilename)); - + const outputFilepath = path.join(assetsDirectory, normalizePath(newFilename)); fs.renameSync(originalFilepath, outputFilepath); return newFilename; } async function removeCustomLogo(logoFilename = LOGO_FILENAME) { if (!logoFilename || !validFilename(logoFilename)) return false; - const assetsDirectory = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets") - : path.join(__dirname, `../../storage/assets`); + const assetsDirectory = getCustomAssetsDirectory(); const logoPath = path.join(assetsDirectory, normalizePath(logoFilename)); if (!isWithin(path.resolve(assetsDirectory), path.resolve(logoPath))) diff --git a/server/utils/files/multer.js b/server/utils/files/multer.js index ee0de4b1159..0485961fd52 100644 --- a/server/utils/files/multer.js +++ b/server/utils/files/multer.js @@ -47,18 +47,19 @@ const fileAPIUploadStorage = multer.diskStorage({ // Asset storage for logos const assetUploadStorage = multer.diskStorage({ destination: function (_, __, cb) { - const uploadOutput = - process.env.NODE_ENV === "development" - ? path.resolve(__dirname, `../../storage/assets`) - : path.resolve(process.env.STORAGE_DIR, "assets"); + const uploadOutput = process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "assets") + : path.resolve(__dirname, `../../storage/assets`); fs.mkdirSync(uploadOutput, { recursive: true }); return cb(null, uploadOutput); }, - filename: function (_, file, cb) { + filename: function (req, file, cb) { file.originalname = normalizePath( Buffer.from(file.originalname, "latin1").toString("utf8") ); - cb(null, file.originalname); + const randomFileName = `${v4()}${path.extname(file.originalname) || ".png"}`; + req.randomFileName = randomFileName; + cb(null, randomFileName); }, }); @@ -67,10 +68,9 @@ const assetUploadStorage = multer.diskStorage({ */ const pfpUploadStorage = multer.diskStorage({ destination: function (_, __, cb) { - const uploadOutput = - process.env.NODE_ENV === "development" - ? path.resolve(__dirname, `../../storage/assets/pfp`) - : path.resolve(process.env.STORAGE_DIR, "assets/pfp"); + const uploadOutput = process.env.STORAGE_DIR + ? path.resolve(process.env.STORAGE_DIR, "assets/pfp") + : path.resolve(__dirname, `../../storage/assets/pfp`); fs.mkdirSync(uploadOutput, { recursive: true }); return cb(null, uploadOutput); }, diff --git a/server/utils/files/pfp.js b/server/utils/files/pfp.js index 2842614dcc6..88608ab536b 100644 --- a/server/utils/files/pfp.js +++ b/server/utils/files/pfp.js @@ -5,6 +5,12 @@ const { User } = require("../../models/user"); const { normalizePath, isWithin } = require("."); const { Workspace } = require("../../models/workspace"); +function getPfpBasePath() { + return process.env.STORAGE_DIR + ? path.join(process.env.STORAGE_DIR, "assets/pfp") + : path.join(__dirname, "../../storage/assets/pfp"); +} + function fetchPfp(pfpPath) { if (!fs.existsSync(pfpPath)) { return { @@ -31,9 +37,7 @@ async function determinePfpFilepath(id) { const pfpFilename = user?.pfpFilename || null; if (!pfpFilename) return null; - const basePath = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets/pfp") - : path.join(__dirname, "../../storage/assets/pfp"); + const basePath = getPfpBasePath(); const pfpFilepath = path.join(basePath, normalizePath(pfpFilename)); if (!isWithin(path.resolve(basePath), path.resolve(pfpFilepath))) return null; @@ -46,9 +50,7 @@ async function determineWorkspacePfpFilepath(slug) { const pfpFilename = workspace?.pfpFilename || null; if (!pfpFilename) return null; - const basePath = process.env.STORAGE_DIR - ? path.join(process.env.STORAGE_DIR, "assets/pfp") - : path.join(__dirname, "../../storage/assets/pfp"); + const basePath = getPfpBasePath(); const pfpFilepath = path.join(basePath, normalizePath(pfpFilename)); if (!isWithin(path.resolve(basePath), path.resolve(pfpFilepath))) return null; @@ -60,4 +62,5 @@ module.exports = { fetchPfp, determinePfpFilepath, determineWorkspacePfpFilepath, + getPfpBasePath, };