-
- {(!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"