diff --git a/server/endpoints/api/workspace/index.js b/server/endpoints/api/workspace/index.js index cf9c5618c8..d816fae51a 100644 --- a/server/endpoints/api/workspace/index.js +++ b/server/endpoints/api/workspace/index.js @@ -23,12 +23,19 @@ function apiWorkspaceEndpoints(app) { #swagger.tags = ['Workspaces'] #swagger.description = 'Create a new workspace' #swagger.requestBody = { - description: 'JSON object containing new display name of workspace.', + description: 'JSON object containing workspace configuration.', required: true, content: { "application/json": { example: { name: "My New Workspace", + similarityThreshold: 0.7, + openAiTemp: 0.7, + openAiHistory: 20, + openAiPrompt: "Custom prompt for responses", + queryRefusalResponse: "Custom refusal message", + chatMode: "chat", + topN: 4 } } } @@ -62,8 +69,18 @@ function apiWorkspaceEndpoints(app) { } */ try { - const { name = null } = reqBody(request); - const { workspace, message } = await Workspace.new(name); + const { name = null, ...additionalFields } = reqBody(request); + const { workspace, message } = await Workspace.new( + name, + null, + additionalFields + ); + + if (!workspace) { + response.status(400).json({ workspace: null, message }); + return; + } + await Telemetry.sendTelemetry("workspace_created", { multiUserMode: multiUserMode(response), LLMSelection: process.env.LLM_PROVIDER || "openai", diff --git a/server/models/workspace.js b/server/models/workspace.js index 5bc9301948..4471f67e7f 100644 --- a/server/models/workspace.js +++ b/server/models/workspace.js @@ -6,14 +6,21 @@ const { ROLES } = require("../utils/middleware/multiUserProtected"); const { v4: uuidv4 } = require("uuid"); const { User } = require("./user"); +function isNullOrNaN(value) { + if (value === null) return true; + return isNaN(value); +} + const Workspace = { defaultPrompt: "Given the following conversation, relevant context, and a follow up question, reply with an answer to the current question the user is asking. Return only your response to the question given the above information following the users instructions as needed.", + + // Used for generic updates so we can validate keys in request body + // commented fields are not writable, but are available on the db object writable: [ - // Used for generic updates so we can validate keys in request body "name", - "slug", - "vectorTag", + // "slug", + // "vectorTag", "openAiTemp", "openAiHistory", "lastUpdatedAt", @@ -23,11 +30,77 @@ const Workspace = { "chatModel", "topN", "chatMode", - "pfpFilename", + // "pfpFilename", "agentProvider", "agentModel", "queryRefusalResponse", ], + + validations: { + name: (value) => { + // If the name is not provided or is not a string then we will use a default name. + // as the name field is not nullable in the db schema or has a default value. + if (!value || typeof value !== "string") return "My Workspace"; + return String(value).slice(0, 255); + }, + openAiTemp: (value) => { + if (value === null || value === undefined) return null; + const temp = parseFloat(value); + if (isNullOrNaN(temp) || temp < 0) return null; + return temp; + }, + openAiHistory: (value) => { + if (value === null || value === undefined) return 20; + const history = parseInt(value); + if (isNullOrNaN(history)) return 20; + if (history < 0) return 0; + return history; + }, + similarityThreshold: (value) => { + if (value === null || value === undefined) return 0.25; + const threshold = parseFloat(value); + if (isNullOrNaN(threshold)) return 0.25; + if (threshold < 0) return 0.0; + if (threshold > 1) return 1.0; + return threshold; + }, + topN: (value) => { + if (value === null || value === undefined) return 4; + const n = parseInt(value); + if (isNullOrNaN(n)) return 4; + if (n < 1) return 1; + return n; + }, + chatMode: (value) => { + if (!value || !["chat", "query"].includes(value)) return "chat"; + return value; + }, + chatProvider: (value) => { + if (!value || typeof value !== "string" || value === "none") return null; + return String(value); + }, + chatModel: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + agentProvider: (value) => { + if (!value || typeof value !== "string" || value === "none") return null; + return String(value); + }, + agentModel: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + queryRefusalResponse: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + openAiPrompt: (value) => { + if (!value || typeof value !== "string") return null; + return String(value); + }, + }, + /** * The default Slugify module requires some additional mapping to prevent downstream issues * with some vector db providers and instead of building a normalization method for every provider @@ -53,8 +126,34 @@ const Workspace = { return slugifyModule(...args); }, - new: async function (name = null, creatorId = null) { - if (!name) return { result: null, message: "name cannot be null" }; + /** + * Validate the fields for a workspace update. + * @param {Object} updates - The updates to validate - should be writable fields + * @returns {Object} The validated updates. Only valid fields are returned. + */ + validateFields: function (updates = {}) { + const validatedFields = {}; + for (const [key, value] of Object.entries(updates)) { + if (!this.writable.includes(key)) continue; + if (this.validations[key]) { + validatedFields[key] = this.validations[key](value); + } else { + // If there is no validation for the field then we will just pass it through. + validatedFields[key] = value; + } + } + return validatedFields; + }, + + /** + * Create a new workspace. + * @param {string} name - The name of the workspace. + * @param {number} creatorId - The ID of the user creating the workspace. + * @param {Object} additionalFields - Additional fields to apply to the workspace - will be validated. + * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the created workspace and an error message if applicable. + */ + new: async function (name = null, creatorId = null, additionalFields = {}) { + if (!name) return { workspace: null, message: "name cannot be null" }; var slug = this.slugify(name, { lower: true }); slug = slug || uuidv4(); @@ -66,7 +165,11 @@ const Workspace = { try { const workspace = await prisma.workspaces.create({ - data: { name, slug }, + data: { + name: this.validations.name(name), + ...this.validateFields(additionalFields), + slug, + }, }); // If created with a user then we need to create the relationship as well. @@ -80,35 +183,36 @@ const Workspace = { } }, + /** + * Update the settings for a workspace. Applies validations to the updates provided. + * @param {number} id - The ID of the workspace to update. + * @param {Object} updates - The data to update. + * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the updated workspace and an error message if applicable. + */ update: async function (id = null, updates = {}) { if (!id) throw new Error("No workspace id provided for update"); - const validFields = Object.keys(updates).filter((key) => - this.writable.includes(key) - ); - - Object.entries(updates).forEach(([key]) => { - if (validFields.includes(key)) return; - delete updates[key]; - }); - - if (Object.keys(updates).length === 0) + const validatedUpdates = this.validateFields(updates); + if (Object.keys(validatedUpdates).length === 0) return { workspace: { id }, message: "No valid fields to update!" }; // If the user unset the chatProvider we will need // to then clear the chatModel as well to prevent confusion during // LLM loading. - if (updates?.chatProvider === "default") { - updates.chatProvider = null; - updates.chatModel = null; + if (validatedUpdates?.chatProvider === "default") { + validatedUpdates.chatProvider = null; + validatedUpdates.chatModel = null; } - return this._update(id, updates); + return this._update(id, validatedUpdates); }, - // Explicit update of settings + key validations. - // Only use this method when directly setting a key value - // that takes no user input for the keys being modified. + /** + * Direct update of workspace settings without any validation. + * @param {number} id - The ID of the workspace to update. + * @param {Object} data - The data to update. + * @returns {Promise<{workspace: Object | null, message: string | null}>} A promise that resolves to an object containing the updated workspace and an error message if applicable. + */ _update: async function (id = null, data = {}) { if (!id) throw new Error("No workspace id provided for update"); diff --git a/server/swagger/openapi.json b/server/swagger/openapi.json index 505d579628..19d14766ce 100644 --- a/server/swagger/openapi.json +++ b/server/swagger/openapi.json @@ -1451,6 +1451,9 @@ } } }, + "400": { + "description": "Bad Request" + }, "403": { "description": "Forbidden", "content": { @@ -1471,12 +1474,19 @@ } }, "requestBody": { - "description": "JSON object containing new display name of workspace.", + "description": "JSON object containing workspace configuration.", "required": true, "content": { "application/json": { "example": { - "name": "My New Workspace" + "name": "My New Workspace", + "similarityThreshold": 0.7, + "openAiTemp": 0.7, + "openAiHistory": 20, + "openAiPrompt": "Custom prompt for responses", + "queryRefusalResponse": "Custom refusal message", + "chatMode": "chat", + "topN": 4 } } }