diff --git a/frontend/src/components/Sidebar/SearchBox/index.jsx b/frontend/src/components/Sidebar/SearchBox/index.jsx new file mode 100644 index 00000000..d5353d3b --- /dev/null +++ b/frontend/src/components/Sidebar/SearchBox/index.jsx @@ -0,0 +1,213 @@ +import { useState, useEffect, useRef } from "react"; +import { Plus, MagnifyingGlass } from "@phosphor-icons/react"; +import { useTranslation } from "react-i18next"; +import { Link } from "react-router-dom"; +import paths from "@/utils/paths"; +import Preloader from "@/components/Preloader"; +import debounce from "lodash.debounce"; +import Workspace from "@/models/workspace"; +import { Tooltip } from "react-tooltip"; + +const DEFAULT_SEARCH_RESULTS = { + workspaces: [], + threads: [], +}; + +const SEARCH_RESULT_SELECTED = "search-result-selected"; +export default function SearchBox({ user, showNewWsModal }) { + const { t } = useTranslation(); + const searchRef = useRef(null); + const [searchTerm, setSearchTerm] = useState(""); + const [loading, setLoading] = useState(false); + const [searchResults, setSearchResults] = useState(DEFAULT_SEARCH_RESULTS); + const handleSearch = debounce(handleSearchDebounced, 500); + + async function handleSearchDebounced(e) { + try { + const searchValue = e.target.value; + setSearchTerm(searchValue); + setLoading(true); + const searchResults = + await Workspace.searchWorkspaceOrThread(searchValue); + setSearchResults(searchResults); + } catch (error) { + console.error(error); + setSearchResults(DEFAULT_SEARCH_RESULTS); + } finally { + setLoading(false); + } + } + + function handleReset() { + searchRef.current.value = ""; + setSearchTerm(""); + setLoading(false); + setSearchResults(DEFAULT_SEARCH_RESULTS); + } + + useEffect(() => { + window.addEventListener(SEARCH_RESULT_SELECTED, handleReset); + return () => + window.removeEventListener(SEARCH_RESULT_SELECTED, handleReset); + }, []); + + return ( +
+
+ e.target.select()} + className="border-none w-full h-full rounded-lg bg-theme-sidebar-item-default pl-4 pr-1 placeholder:text-theme-settings-input-placeholder placeholder:pl-4 outline-none text-white search-input peer text-sm" + /> +
+ + +
+ ); +} + +function SearchResultWrapper({ children }) { + return ( +
+ {children} +
+ ); +} + +function SearchResults({ searchResults, searchTerm, loading }) { + if (!searchTerm || searchTerm.length < 3) return null; + if (loading) + return ( + +
+ +

+ Searching for "{searchTerm}" +

+
+
+ ); + + if ( + searchResults.workspaces.length === 0 && + searchResults.threads.length === 0 + ) { + return ( + +
+

+ No results found for +
+ + "{searchTerm}" + +

+
+
+ ); + } + + return ( + + ({ + id: workspace.slug, + to: paths.workspace.chat(workspace.slug), + name: workspace.name, + }))} + /> + ({ + id: thread.slug, + to: paths.workspace.thread(thread.workspace.slug, thread.slug), + name: thread.name, + hint: thread.workspace.name, + }))} + /> + + ); +} + +function SearchResultCategory({ items, name }) { + if (!items?.length) return null; + return ( +
+

+ {name} +

+
+ {items.map((item) => ( + + ))} +
+
+ ); +} + +function SearchResultItem({ to, name, hint }) { + return ( + window.dispatchEvent(new Event(SEARCH_RESULT_SELECTED))} + className="hover:bg-[#FFF]/10 light:hover:bg-[#000]/10 transition-all duration-300 rounded-sm px-[8px] py-[2px]" + > +

+ {name} + {hint && ( + + | {hint} + + )} +

+ + ); +} + +function ShortWidthNewWorkspaceButton({ user, showNewWsModal }) { + const { t } = useTranslation(); + if (!!user && user?.role === "default") return null; + + return ( + <> + + + + ); +} diff --git a/frontend/src/components/Sidebar/index.jsx b/frontend/src/components/Sidebar/index.jsx index 04a7fe43..1ffe870d 100644 --- a/frontend/src/components/Sidebar/index.jsx +++ b/frontend/src/components/Sidebar/index.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; -import { Plus, List } from "@phosphor-icons/react"; +import { List, Plus } from "@phosphor-icons/react"; import NewWorkspaceModal, { useNewWorkspaceModal, } from "../Modals/NewWorkspace"; @@ -12,6 +12,7 @@ import { Link } from "react-router-dom"; import paths from "@/utils/paths"; import { useTranslation } from "react-i18next"; import { useSidebarToggle, ToggleSidebarButton } from "./SidebarToggle"; +import SearchBox from "./SearchBox"; export default function Sidebar() { const { user } = useUser(); @@ -58,20 +59,8 @@ export default function Sidebar() {
-
-
- {(!user || user?.role !== "default") && ( - - )} -
+
+
@@ -177,19 +166,10 @@ export function SidebarMobileHeader() {
-
- {(!user || user?.role !== "default") && ( - - )} -
+
@@ -204,3 +184,22 @@ export function SidebarMobileHeader() { ); } + +function NewWorkspaceButton({ user, showNewWsModal }) { + const { t } = useTranslation(); + if (!!user && user?.role === "default") return null; + + return ( +
+ +
+ ); +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8c4ff7e1..4f739aba 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1023,6 +1023,10 @@ does not extend the close button beyond the viewport. */ filter: grayscale(100%) invert(1) brightness(100) opacity(0.5); } +[data-theme="light"] .search-input::-webkit-search-cancel-button { + filter: grayscale(100%) invert(0) brightness(0) opacity(0.5); +} + .animate-remove { animation: fadeAndShrink 800ms forwards; } diff --git a/frontend/src/locales/ar/common.js b/frontend/src/locales/ar/common.js index 704c4570..5dc02393 100644 --- a/frontend/src/locales/ar/common.js +++ b/frontend/src/locales/ar/common.js @@ -68,6 +68,7 @@ const TRANSLATIONS = { optional: "اختياري", yes: "نعم", no: "لا", + search: null, }, settings: { title: "إعدادات المثيل", diff --git a/frontend/src/locales/da/common.js b/frontend/src/locales/da/common.js index d52bf91e..440d348e 100644 --- a/frontend/src/locales/da/common.js +++ b/frontend/src/locales/da/common.js @@ -70,6 +70,7 @@ const TRANSLATIONS = { optional: "Valgfrit", yes: "Ja", no: "Nej", + search: null, }, settings: { title: "Instansindstillinger", diff --git a/frontend/src/locales/de/common.js b/frontend/src/locales/de/common.js index d91ef143..9be56f1d 100644 --- a/frontend/src/locales/de/common.js +++ b/frontend/src/locales/de/common.js @@ -70,6 +70,7 @@ const TRANSLATIONS = { optional: "Optional", yes: "Ja", no: "Nein", + search: null, }, settings: { title: "Instanzeinstellungen", diff --git a/frontend/src/locales/en/common.js b/frontend/src/locales/en/common.js index c71195d2..130d35bc 100644 --- a/frontend/src/locales/en/common.js +++ b/frontend/src/locales/en/common.js @@ -70,6 +70,7 @@ const TRANSLATIONS = { optional: "Optional", yes: "Yes", no: "No", + search: "Search", }, // Setting Sidebar menu items. diff --git a/frontend/src/locales/es/common.js b/frontend/src/locales/es/common.js index a8b27af8..3c0ad99e 100644 --- a/frontend/src/locales/es/common.js +++ b/frontend/src/locales/es/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "Configuración de instancia", diff --git a/frontend/src/locales/et/common.js b/frontend/src/locales/et/common.js index fb46c046..e46989fc 100644 --- a/frontend/src/locales/et/common.js +++ b/frontend/src/locales/et/common.js @@ -68,6 +68,7 @@ const TRANSLATIONS = { optional: "Valikuline", yes: "Jah", no: "Ei", + search: null, }, settings: { title: "Instantsi seaded", diff --git a/frontend/src/locales/fa/common.js b/frontend/src/locales/fa/common.js index 62784935..311512c5 100644 --- a/frontend/src/locales/fa/common.js +++ b/frontend/src/locales/fa/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "تنظیمات سامانه", diff --git a/frontend/src/locales/fr/common.js b/frontend/src/locales/fr/common.js index b6062210..07c49d8f 100644 --- a/frontend/src/locales/fr/common.js +++ b/frontend/src/locales/fr/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "Paramètres de l'instance", diff --git a/frontend/src/locales/he/common.js b/frontend/src/locales/he/common.js index 0e07430c..4576a24f 100644 --- a/frontend/src/locales/he/common.js +++ b/frontend/src/locales/he/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "הגדרות מופע", diff --git a/frontend/src/locales/it/common.js b/frontend/src/locales/it/common.js index 32eefc8a..a9eaef33 100644 --- a/frontend/src/locales/it/common.js +++ b/frontend/src/locales/it/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "Impostazioni istanza", diff --git a/frontend/src/locales/ja/common.js b/frontend/src/locales/ja/common.js index 03fd4af3..9d079900 100644 --- a/frontend/src/locales/ja/common.js +++ b/frontend/src/locales/ja/common.js @@ -69,6 +69,7 @@ const TRANSLATIONS = { optional: "任意", yes: "はい", no: "いいえ", + search: null, }, settings: { title: "インスタンス設定", diff --git a/frontend/src/locales/ko/common.js b/frontend/src/locales/ko/common.js index 3935611e..fa92bcb4 100644 --- a/frontend/src/locales/ko/common.js +++ b/frontend/src/locales/ko/common.js @@ -68,6 +68,7 @@ const TRANSLATIONS = { optional: "선택 사항", yes: "예", no: "아니오", + search: null, }, settings: { title: "인스턴스 설정", diff --git a/frontend/src/locales/lv/common.js b/frontend/src/locales/lv/common.js index 07a2e8c2..69f839db 100644 --- a/frontend/src/locales/lv/common.js +++ b/frontend/src/locales/lv/common.js @@ -69,6 +69,7 @@ const TRANSLATIONS = { optional: "Neobligāti", yes: "Jā", no: "Nē", + search: null, }, settings: { title: "Instances iestatījumi", diff --git a/frontend/src/locales/nl/common.js b/frontend/src/locales/nl/common.js index 65999897..454d00d2 100644 --- a/frontend/src/locales/nl/common.js +++ b/frontend/src/locales/nl/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "Instelling Instanties", diff --git a/frontend/src/locales/pl/common.js b/frontend/src/locales/pl/common.js index 9e76227e..dadb4c33 100644 --- a/frontend/src/locales/pl/common.js +++ b/frontend/src/locales/pl/common.js @@ -70,6 +70,7 @@ const TRANSLATIONS = { optional: "Opcjonalnie", yes: "Tak", no: "Nie", + search: null, }, settings: { title: "Ustawienia instancji", diff --git a/frontend/src/locales/pt_BR/common.js b/frontend/src/locales/pt_BR/common.js index 443de0c4..03db30e8 100644 --- a/frontend/src/locales/pt_BR/common.js +++ b/frontend/src/locales/pt_BR/common.js @@ -68,6 +68,7 @@ const TRANSLATIONS = { optional: "Opcional", yes: "Sim", no: "Não", + search: null, }, settings: { title: "Configurações da Instância", diff --git a/frontend/src/locales/ru/common.js b/frontend/src/locales/ru/common.js index babd3b80..d1df59b8 100644 --- a/frontend/src/locales/ru/common.js +++ b/frontend/src/locales/ru/common.js @@ -69,6 +69,7 @@ const TRANSLATIONS = { optional: "Необязательный", yes: "Да", no: "Нет", + search: null, }, settings: { title: "Настройки экземпляра", diff --git a/frontend/src/locales/tr/common.js b/frontend/src/locales/tr/common.js index bd7940ff..2701131f 100644 --- a/frontend/src/locales/tr/common.js +++ b/frontend/src/locales/tr/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "Instance Ayarları", diff --git a/frontend/src/locales/vn/common.js b/frontend/src/locales/vn/common.js index 1af3ad1a..3bd30db4 100644 --- a/frontend/src/locales/vn/common.js +++ b/frontend/src/locales/vn/common.js @@ -61,6 +61,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "Cài đặt hệ thống", diff --git a/frontend/src/locales/zh/common.js b/frontend/src/locales/zh/common.js index e84daa4a..e189ed27 100644 --- a/frontend/src/locales/zh/common.js +++ b/frontend/src/locales/zh/common.js @@ -65,6 +65,7 @@ const TRANSLATIONS = { optional: "可选", yes: "是", no: "否", + search: null, }, settings: { title: "设置", diff --git a/frontend/src/locales/zh_TW/common.js b/frontend/src/locales/zh_TW/common.js index 0268b03f..34534625 100644 --- a/frontend/src/locales/zh_TW/common.js +++ b/frontend/src/locales/zh_TW/common.js @@ -65,6 +65,7 @@ const TRANSLATIONS = { optional: null, yes: null, no: null, + search: null, }, settings: { title: "系統設定", diff --git a/frontend/src/models/workspace.js b/frontend/src/models/workspace.js index c9f72084..8abdcfe3 100644 --- a/frontend/src/models/workspace.js +++ b/frontend/src/models/workspace.js @@ -508,6 +508,25 @@ const Workspace = { return orderedWorkspaces; }, + /** + * Searches for workspaces and threads + * @param {string} searchTerm + * @returns {Promise<{workspaces: [{slug: string, name: string}], threads: [{slug: string, name: string, workspace: {slug: string, name: string}}]}}>} + */ + searchWorkspaceOrThread: async function (searchTerm) { + const response = await fetch(`${API_BASE}/workspace/search`, { + method: "POST", + headers: baseHeaders(), + body: JSON.stringify({ searchTerm }), + }) + .then((res) => res.json()) + .catch((e) => { + console.error(e); + return { workspaces: [], threads: [] }; + }); + return response; + }, + threads: WorkspaceThread, }; diff --git a/server/endpoints/workspaces.js b/server/endpoints/workspaces.js index 547d0339..66605d65 100644 --- a/server/endpoints/workspaces.js +++ b/server/endpoints/workspaces.js @@ -35,6 +35,7 @@ const { WorkspaceThread } = require("../models/workspaceThread"); const truncate = require("truncate"); const { purgeDocument } = require("../utils/files/purgeDocument"); const { getModelTag } = require("./utils"); +const { searchWorkspaceAndThreads } = require("../utils/helpers/search"); function workspaceEndpoints(app) { if (!app) return; @@ -1037,6 +1038,28 @@ function workspaceEndpoints(app) { } } ); + + /** + * Searches for workspaces and threads by thread name or workspace name. + * Only returns assets owned by the user (if multi-user mode is enabled). + */ + app.post( + "/workspace/search", + [validatedRequest, flexUserRoleValid([ROLES.all])], + async (request, response) => { + try { + const { searchTerm } = reqBody(request); + const searchResults = await searchWorkspaceAndThreads( + searchTerm, + response.locals?.user + ); + response.status(200).json(searchResults); + } catch (error) { + console.error("Error searching for workspaces:", error); + response.sendStatus(500).end(); + } + } + ); } module.exports = { workspaceEndpoints }; diff --git a/server/models/workspaceThread.js b/server/models/workspaceThread.js index 1ac6040c..58e895a1 100644 --- a/server/models/workspaceThread.js +++ b/server/models/workspaceThread.js @@ -100,12 +100,18 @@ const WorkspaceThread = { } }, - where: async function (clause = {}, limit = null, orderBy = null) { + where: async function ( + clause = {}, + limit = null, + orderBy = null, + include = null + ) { try { const results = await prisma.workspace_threads.findMany({ where: clause, ...(limit !== null ? { take: limit } : {}), ...(orderBy !== null ? { orderBy } : {}), + ...(include !== null ? { include } : {}), }); return results; } catch (error) { diff --git a/server/package.json b/server/package.json index bab1a14c..f8e31289 100644 --- a/server/package.json +++ b/server/package.json @@ -52,6 +52,7 @@ "elevenlabs": "^0.5.0", "express": "^4.18.2", "extract-json-from-string": "^1.0.1", + "fast-levenshtein": "^3.0.0", "graphql": "^16.7.1", "joi": "^17.11.0", "joi-password-complexity": "^5.2.0", @@ -99,4 +100,4 @@ "nodemon": "^2.0.22", "prettier": "^3.0.3" } -} \ No newline at end of file +} diff --git a/server/utils/helpers/search.js b/server/utils/helpers/search.js new file mode 100644 index 00000000..3f1bd880 --- /dev/null +++ b/server/utils/helpers/search.js @@ -0,0 +1,94 @@ +const { Workspace } = require("../../models/workspace"); +const { WorkspaceThread } = require("../../models/workspaceThread"); +const fastLevenshtein = require("fast-levenshtein"); + +// allow a pretty loose levenshtein distance for the search +// since we would rather show a few more results than less +const FAST_LEVENSHTEIN_DISTANCE = 3; + +/** + * Search for workspaces and threads based on a search term with optional user context. + * For each type of item we are looking at the `name` field. + * - If the normalized name, starts with, includes, or ends with the search term => match + * - If the normalized name is within 2 levenshtein distance of the search term => match + * @param {string} searchTerm - The search term to search for. + * @param {Object} user - The user to search for. + * @returns {Promise<{workspaces: Array<{slug: string, name: string}>, threads: Array<{slug: string, name: string, workspace: {slug: string, name: string}}>}>} - The search results. + */ +async function searchWorkspaceAndThreads(searchTerm, user = null) { + searchTerm = String(searchTerm).trim(); // Ensure searchTerm is a string and trimmed. + + if (!searchTerm || searchTerm.length < 3) + return { workspaces: [], threads: [] }; + searchTerm = searchTerm.toLowerCase(); + + // To prevent duplicates in O(1) time, we use sets which will be + // STRINGIFIED results of matching workspaces or threads. We then + // parse them back into objects at the end. + const results = { + workspaces: new Set(), + threads: new Set(), + }; + + async function searchWorkspaces() { + const workspaces = !!user + ? await Workspace.whereWithUser(user) + : await Workspace.where(); + + for (const workspace of workspaces) { + const wsName = workspace.name.toLowerCase(); + if ( + wsName.startsWith(searchTerm) || + wsName.includes(searchTerm) || + wsName.endsWith(searchTerm) || + fastLevenshtein.get(wsName, searchTerm) <= FAST_LEVENSHTEIN_DISTANCE + ) + results.workspaces.add( + JSON.stringify({ slug: workspace.slug, name: workspace.name }) + ); + } + } + + async function searchThreads() { + const threads = !!user + ? await WorkspaceThread.where( + { user_id: user.id }, + undefined, + undefined, + { workspace: { select: { slug: true, name: true } } } + ) + : await WorkspaceThread.where(undefined, undefined, undefined, { + workspace: { select: { slug: true, name: true } }, + }); + + for (const thread of threads) { + const threadName = thread.name.toLowerCase(); + if ( + threadName.startsWith(searchTerm) || + threadName.includes(searchTerm) || + threadName.endsWith(searchTerm) || + fastLevenshtein.get(threadName, searchTerm) <= FAST_LEVENSHTEIN_DISTANCE + ) + results.threads.add( + JSON.stringify({ + slug: thread.slug, + name: thread.name, + workspace: { + slug: thread.workspace.slug, + name: thread.workspace.name, + }, + }) + ); + } + } + + // Run both searches in parallel - this modifies the results set in place. + await Promise.all([searchWorkspaces(), searchThreads()]); + + // Parse the results back into objects. + const workspaces = Array.from(results.workspaces).map(JSON.parse); + const threads = Array.from(results.threads).map(JSON.parse); + return { workspaces, threads }; +} + +module.exports = { searchWorkspaceAndThreads }; diff --git a/server/yarn.lock b/server/yarn.lock index 34da5c41..e0abfad8 100644 --- a/server/yarn.lock +++ b/server/yarn.lock @@ -4467,6 +4467,13 @@ fast-levenshtein@^2.0.6: resolved "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz" integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== +fast-levenshtein@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-3.0.0.tgz#37b899ae47e1090e40e3fd2318e4d5f0142ca912" + integrity sha512-hKKNajm46uNmTlhHSyZkmToAc56uZJwYq7yrciZjqOxnlfQwERDQJmHPUp7m1m9wx8vgOe8IaCKZ5Kv2k1DdCQ== + dependencies: + fastest-levenshtein "^1.0.7" + fast-xml-parser@4.2.5: version "4.2.5" resolved "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.2.5.tgz" @@ -4488,6 +4495,11 @@ fast-xml-parser@^4.3.5: dependencies: strnum "^1.0.5" +fastest-levenshtein@^1.0.7: + version "1.0.16" + resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" + integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== + fastq@^1.6.0: version "1.17.1" resolved "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz"