Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions frontend/src/components/Modals/NewWorkspace.jsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useRef, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { X } from "@phosphor-icons/react";
import Workspace from "@/models/workspace";
import WorkspaceTemplate from "@/models/workspaceTemplate";
import paths from "@/utils/paths";
import { useTranslation } from "react-i18next";
import ModalWrapper from "@/components/ModalWrapper";
Expand All @@ -9,13 +10,27 @@ const noop = () => false;
export default function NewWorkspaceModal({ hideModal = noop }) {
const formEl = useRef(null);
const [error, setError] = useState(null);
const [templates, setTemplates] = useState([]);
const [selectedTemplateId, setSelectedTemplateId] = useState("");
const { t } = useTranslation();

useEffect(() => {
async function fetchTemplates() {
const allTemplates = await WorkspaceTemplate.all();
setTemplates(allTemplates);
}
fetchTemplates();
}, []);

const handleCreate = async (e) => {
setError(null);
e.preventDefault();
const data = {};
const form = new FormData(formEl.current);
for (var [key, value] of form.entries()) data[key] = value;
if (selectedTemplateId) {
data.templateId = selectedTemplateId;
}
const { workspace, message } = await Workspace.new(data);
if (!!workspace) {
window.location.href = paths.workspace.chat(workspace.slug);
Expand Down Expand Up @@ -58,13 +73,36 @@ export default function NewWorkspaceModal({ hideModal = noop }) {
name="name"
type="text"
id="name"
className="border-none bg-theme-settings-input-bg w-full text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block w-full p-2.5"
className="border-none bg-theme-settings-input-bg w-full text-white placeholder:text-theme-settings-input-placeholder text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block p-2.5"
placeholder={t("new-workspace.placeholder")}
required={true}
autoComplete="off"
autoFocus={true}
/>
</div>
{templates.length > 0 && (
<div>
<label
htmlFor="template"
className="block mb-2 text-sm font-medium text-white"
>
Template
</label>
<select
id="template"
value={selectedTemplateId}
onChange={(e) => setSelectedTemplateId(e.target.value)}
className="border-none bg-theme-settings-input-bg w-full text-white text-sm rounded-lg focus:outline-primary-button active:outline-primary-button outline-none block p-2.5"
>
<option value="">System Default</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name}
</option>
))}
</select>
</div>
)}
{error && (
<p className="text-red-400 text-sm">Error: {error}</p>
)}
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/components/SettingsSidebar/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,12 @@ const SidebarOptions = ({ user = null, t }) => (
flex: true,
roles: ["admin", "manager"],
},
{
btnText: t("settings.workspace-templates"),
href: paths.settings.workspaceTemplates(),
flex: true,
roles: ["admin", "manager"],
},
{
btnText: t("settings.mobile-app"),
href: paths.settings.mobile(),
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ const TRANSLATIONS = {
"experimental-features": "Experimental Features",
contact: "Contact Support",
"browser-extension": "Browser Extension",
"workspace-templates": "Workspace Templates",
"mobile-app": "AnythingLLM Mobile",
},

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,15 @@ const router = createBrowserRouter([
};
},
},
{
path: "/settings/workspace-templates",
lazy: async () => {
const { default: WorkspaceTemplates } = await import(
"@/pages/GeneralSettings/WorkspaceTemplates"
);
return { element: <ManagerRoute Component={WorkspaceTemplates} /> };
},
},
{
path: "/settings/mobile-connections",
lazy: async () => {
Expand Down
63 changes: 63 additions & 0 deletions frontend/src/models/workspaceTemplate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { API_BASE } from "@/utils/constants";
import { baseHeaders } from "@/utils/request";

const WorkspaceTemplate = {
create: async function ({ name, description, workspaceSlug = null }) {
const { template, message } = await fetch(
`${API_BASE}/workspace-templates`,
{
method: "POST",
body: JSON.stringify({ name, description, workspaceSlug }),
headers: baseHeaders(),
}
)
.then((res) => res.json())
.catch((e) => {
return { template: null, message: e.message };
});

return { template, message };
},

all: async function () {
const templates = await fetch(`${API_BASE}/workspace-templates`, {
method: "GET",
headers: baseHeaders(),
})
.then((res) => res.json())
.then((res) => res.templates || [])
.catch(() => []);

return templates;
},

delete: async function (id) {
const result = await fetch(`${API_BASE}/workspace-templates/${id}`, {
method: "DELETE",
headers: baseHeaders(),
})
.then((res) => res.ok)
.catch(() => false);

return result;
},

update: async function (id, { name, description, config }) {
const { template, message } = await fetch(
`${API_BASE}/workspace-templates/${id}`,
{
method: "PUT",
body: JSON.stringify({ name, description, config }),
headers: baseHeaders(),
}
)
.then((res) => res.json())
.catch((e) => {
return { template: null, message: e.message };
});

return { template, message };
},
};

export default WorkspaceTemplate;
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useState, useRef, useEffect } from "react";
import { CaretDown } from "@phosphor-icons/react";

/**
* custom select to allow for themed scrollbar
*/
export default function CustomSelect({
value,
options = [], // [{ value: string, label: string }]
onChange,
placeholder = "Select...",
disabled = false,
className = "",
}) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef(null);

// close on click outside
useEffect(() => {
function handleClickOutside(e) {
if (containerRef.current && !containerRef.current.contains(e.target)) {
setIsOpen(false);
}
}
if (isOpen) {
document.addEventListener("mousedown", handleClickOutside);
return () =>
document.removeEventListener("mousedown", handleClickOutside);
}
}, [isOpen]);

// close on escape
useEffect(() => {
function handleKeyDown(e) {
if (e.key === "Escape") setIsOpen(false);
}
if (isOpen) {
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}
}, [isOpen]);

const selectedOption = options.find((opt) => opt.value === value);
const displayLabel = selectedOption?.label || placeholder;

return (
<div ref={containerRef} className={`relative ${className}`}>
<button
type="button"
disabled={disabled}
onClick={() => !disabled && setIsOpen(!isOpen)}
className={`w-full bg-theme-settings-input-bg text-theme-text-primary text-sm rounded px-2 py-1 outline-none focus:ring-1 focus:ring-primary-button border border-theme-modal-border h-8 flex items-center justify-between gap-x-1 ${
disabled ? "opacity-60 cursor-not-allowed" : "cursor-pointer"
}`}
>
<span className={`truncate ${!selectedOption ? "opacity-60" : ""}`}>
{displayLabel}
</span>
<CaretDown size={12} weight="bold" className="shrink-0" />
</button>

{isOpen && (
<div className="absolute z-50 mt-1 w-full bg-theme-settings-input-bg border border-theme-modal-border rounded shadow-lg max-h-48 overflow-y-auto show-scrollbar">
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => {
onChange(opt.value === "" ? null : opt.value);
setIsOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-theme-sidebar-item-hover ${
opt.value === value
? "text-theme-text-primary bg-theme-sidebar-item-hover"
: "text-theme-text-primary opacity-80"
}`}
>
{opt.label}
</button>
))}
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import useGetProviderModels from "@/hooks/useGetProvidersModels";
import CustomSelect from "../CustomSelect";

// Providers that require manual model name entry (no model list API)
const FREE_FORM_PROVIDERS = ["bedrock", "azure", "generic-openai"];

// Providers with no model selection needed
const NO_MODEL_PROVIDERS = ["default", "huggingface", "none", ""];

export default function ModelSelect({
provider,
value,
onChange,
className = "",
}) {
const { defaultModels, customModels, loading } = useGetProviderModels(
provider && !NO_MODEL_PROVIDERS.includes(provider) ? provider : null
);

// No model selection needed
if (!provider || NO_MODEL_PROVIDERS.includes(provider)) {
return (
<CustomSelect
value=""
options={[{ value: "", label: "Uses system model" }]}
onChange={() => {}}
disabled
className={className}
/>
);
}

// Free-form text input for providers that require manual model entry
if (FREE_FORM_PROVIDERS.includes(provider)) {
return (
<input
type="text"
value={value || ""}
onChange={(e) =>
onChange(e.target.value === "" ? null : e.target.value)
}
placeholder="Enter model name"
className={`w-full bg-theme-settings-input-bg text-theme-text-primary text-sm rounded px-2 py-1 outline-none focus:ring-1 focus:ring-primary-button border border-theme-modal-border h-8 text-right ${className}`}
/>
);
}

if (loading) {
return (
<CustomSelect
value=""
options={[{ value: "", label: "Loading models..." }]}
onChange={() => {}}
disabled
className={className}
/>
);
}

const options = [{ value: "", label: "System default" }];

if (defaultModels.length > 0) {
defaultModels.forEach((model) => {
options.push({ value: model, label: model });
});
}

if (Array.isArray(customModels)) {
customModels.forEach((model) => {
const id = model.id || model;
options.push({ value: id, label: id });
});
} else if (customModels && typeof customModels === "object") {
Object.entries(customModels).forEach(([org, models]) => {
models.forEach((model) => {
options.push({
value: model.id,
label: `${model.name || model.id} (${org})`,
});
});
});
}

if (options.length === 1) {
return (
<input
type="text"
value={value || ""}
onChange={(e) =>
onChange(e.target.value === "" ? null : e.target.value)
}
placeholder="Enter model name"
className={`w-full bg-theme-settings-input-bg text-theme-text-primary text-sm rounded px-2 py-1 outline-none focus:ring-1 focus:ring-primary-button border border-theme-modal-border h-8 text-right ${className}`}
/>
);
}

return (
<CustomSelect
value={value || ""}
options={options}
onChange={onChange}
placeholder="System default"
className={className}
/>
);
}
Loading
Loading