diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js
index f3f8b5d34b6..9cdc4579856 100644
--- a/api/server/controllers/UserController.js
+++ b/api/server/controllers/UserController.js
@@ -548,6 +548,7 @@ const deleteUserController = async (req, res) => {
await db.deleteAllUserMemories(user.id);
await db.deleteUserPrompts(user.id);
await db.deleteUserSkills(user.id);
+ await db.deleteUserArtifactApps(user.id);
await deleteUserMcpServers(user.id);
await db.deleteActions({ user: user.id });
await db.deleteTokens({ userId: user.id });
diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js
index 34d932263d6..6be85cd9691 100644
--- a/api/server/controllers/UserController.spec.js
+++ b/api/server/controllers/UserController.spec.js
@@ -60,6 +60,7 @@ jest.mock('~/models', () => {
cancelAgentTriggerUserDeletion: jest.fn().mockResolvedValue(true),
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
+ deleteUserArtifactApps: jest.fn().mockResolvedValue(undefined),
deleteUserCodeEnvironments: jest.fn().mockResolvedValue(undefined),
deleteMessages: jest.fn().mockResolvedValue(undefined),
deleteBalances: jest.fn().mockResolvedValue(undefined),
diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js
index 15cb6b8ba9c..4cb799efc44 100644
--- a/api/server/controllers/__tests__/deleteUser.spec.js
+++ b/api/server/controllers/__tests__/deleteUser.spec.js
@@ -19,6 +19,7 @@ const mockDeleteToolCalls = jest.fn();
const mockDeleteUserAgents = jest.fn();
const mockDeleteUserPrompts = jest.fn();
const mockDeleteUserSkills = jest.fn();
+const mockDeleteUserArtifactApps = jest.fn();
const mockDeleteUserCodeEnvironments = jest.fn();
const mockInvalidateCodeEnvironmentConfigCache = jest.fn();
const mockGetCleanupBlockingJobIdsForUser = jest.fn();
@@ -106,6 +107,7 @@ jest.mock('~/models', () => ({
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
deleteUserSkills: (...args) => mockDeleteUserSkills(...args),
+ deleteUserArtifactApps: (...args) => mockDeleteUserArtifactApps(...args),
deleteUserCodeEnvironments: (...args) => mockDeleteUserCodeEnvironments(...args),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js
index a0806d6e3cd..bd49f8b02f3 100644
--- a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js
+++ b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js
@@ -18,6 +18,7 @@ const HANDLED_RESOURCE_TYPES = {
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
[ResourceType.SKILL]: 'deleteUserSkills',
[ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup',
+ [ResourceType.ARTIFACT_APP]: 'deleteUserArtifactApps',
};
/**
diff --git a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js
index 53c36b7719e..1dba60e7b8d 100644
--- a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js
+++ b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js
@@ -147,6 +147,8 @@ jest.mock('~/models', () => ({
deleteToolCalls: jest.fn(),
deleteUserAgents: jest.fn(),
deleteUserPrompts: jest.fn(),
+ deleteUserSkills: jest.fn(),
+ deleteUserArtifactApps: jest.fn(),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
diff --git a/api/server/index.js b/api/server/index.js
index 9d28fc7a17a..d104350de7d 100644
--- a/api/server/index.js
+++ b/api/server/index.js
@@ -426,6 +426,7 @@ const startServer = async () => {
app.use('/api/projects', routes.projects);
app.use('/api/prompts', routes.prompts);
app.use('/api/skills', routes.skills);
+ app.use('/api/artifact-apps', routes.artifactApps);
app.use('/api/categories', routes.categories);
app.use('/api/endpoints', routes.endpoints);
app.use('/api/balance', routes.balance);
diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.js
new file mode 100644
index 00000000000..0df48493863
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.js
@@ -0,0 +1,34 @@
+const { ResourceType } = require('librechat-data-provider');
+const { canAccessResource } = require('./canAccessResource');
+const { resolveArtifactAppId } = require('~/models');
+
+/**
+ * Artifact-app-specific middleware factory that checks access permissions.
+ * Wraps the generic `canAccessResource` with the ARTIFACT_APP resource type and
+ * `resolveArtifactAppId` (custom `app_*` id → ObjectId) as the ID resolver.
+ *
+ * @param {Object} options
+ * @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share)
+ * @param {string} [options.resourceIdParam='id'] - Route parameter holding the artifact app id
+ * @returns {Function} Express middleware
+ */
+const canAccessArtifactAppResource = (options) => {
+ const { requiredPermission, resourceIdParam = 'id' } = options || {};
+
+ if (!requiredPermission || typeof requiredPermission !== 'number') {
+ throw new Error(
+ 'canAccessArtifactAppResource: requiredPermission is required and must be a number',
+ );
+ }
+
+ return canAccessResource({
+ resourceType: ResourceType.ARTIFACT_APP,
+ requiredPermission,
+ resourceIdParam,
+ idResolver: (artifactAppId) => resolveArtifactAppId({ artifactAppId }),
+ });
+};
+
+module.exports = {
+ canAccessArtifactAppResource,
+};
diff --git a/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js
new file mode 100644
index 00000000000..756bb35586a
--- /dev/null
+++ b/api/server/middleware/accessResources/canAccessArtifactAppResource.spec.js
@@ -0,0 +1,203 @@
+const mongoose = require('mongoose');
+const { MongoMemoryServer } = require('mongodb-memory-server');
+const { ResourceType, PrincipalType, PrincipalModel } = require('librechat-data-provider');
+const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource');
+const { User, Role, AclEntry } = require('~/db/models');
+const { createArtifactAppWithVersion } = require('~/models');
+
+const VIEW = 1;
+const EDIT = 2;
+const DELETE = 4;
+
+/** Owner ACL bits (view+edit+delete+share), matching what publish grants. */
+const OWNER_BITS = 15;
+
+describe('canAccessArtifactAppResource middleware', () => {
+ let mongoServer;
+ let req, res, next;
+ let testUser;
+
+ const publishApp = async (createdBy) =>
+ createArtifactAppWithVersion({
+ title: 'Test App',
+ visibility: 'private',
+ createdBy: String(createdBy),
+ version: {
+ artifactType: 'react',
+ sourceSnapshot: 'export default () =>
hi
;',
+ createdBy: String(createdBy),
+ },
+ });
+
+ const grant = async ({ principalId, resourceId, permBits, grantedBy }) =>
+ AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.ARTIFACT_APP,
+ resourceId,
+ permBits,
+ grantedBy: grantedBy ?? principalId,
+ });
+
+ beforeAll(async () => {
+ mongoServer = await MongoMemoryServer.create();
+ await mongoose.connect(mongoServer.getUri());
+ });
+
+ afterAll(async () => {
+ await mongoose.disconnect();
+ await mongoServer.stop();
+ });
+
+ beforeEach(async () => {
+ await mongoose.connection.dropDatabase();
+ await Role.create({ name: 'test-role', permissions: {} });
+ testUser = await User.create({
+ email: 'test@example.com',
+ name: 'Test User',
+ username: 'testuser',
+ role: 'test-role',
+ });
+
+ req = { user: { id: testUser._id, role: testUser.role }, params: {} };
+ res = { status: jest.fn().mockReturnThis(), json: jest.fn() };
+ next = jest.fn();
+ jest.clearAllMocks();
+ });
+
+ describe('middleware factory', () => {
+ test('throws when requiredPermission is missing', () => {
+ expect(() => canAccessArtifactAppResource({})).toThrow(
+ 'canAccessArtifactAppResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('throws when requiredPermission is not a number', () => {
+ expect(() => canAccessArtifactAppResource({ requiredPermission: '1' })).toThrow(
+ 'canAccessArtifactAppResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('throws when called with no options at all', () => {
+ expect(() => canAccessArtifactAppResource()).toThrow(
+ 'canAccessArtifactAppResource: requiredPermission is required and must be a number',
+ );
+ });
+
+ test('returns an express middleware with the default resourceIdParam', () => {
+ const middleware = canAccessArtifactAppResource({ requiredPermission: VIEW });
+ expect(typeof middleware).toBe('function');
+ expect(middleware.length).toBe(3);
+ });
+ });
+
+ describe('permission checking against real ACL entries', () => {
+ test('allows the owner to view their own app', async () => {
+ const { app } = await publishApp(testUser._id);
+ await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS });
+ req.params.id = app.artifactAppId;
+
+ await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('denies a user with no ACL entry for the app', async () => {
+ const otherUser = await User.create({
+ email: 'other@example.com',
+ name: 'Other User',
+ username: 'otheruser',
+ role: 'test-role',
+ });
+ const { app } = await publishApp(otherUser._id);
+ await grant({ principalId: otherUser._id, resourceId: app.id, permBits: OWNER_BITS });
+ req.params.id = app.artifactAppId;
+
+ await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Forbidden',
+ message: `Insufficient permissions to access this ${ResourceType.ARTIFACT_APP}`,
+ });
+ });
+
+ /* The route table gates PATCH on EDIT and DELETE on DELETE, so a
+ * view-only grant must not satisfy either. */
+ test.each([
+ ['edit', EDIT],
+ ['delete', DELETE],
+ ])('denies %s when the grant is view-only', async (_label, requiredPermission) => {
+ const { app } = await publishApp(testUser._id);
+ await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW });
+ req.params.id = app.artifactAppId;
+
+ await canAccessArtifactAppResource({ requiredPermission })(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('allows edit when the grant carries edit bits', async () => {
+ const { app } = await publishApp(testUser._id);
+ await grant({ principalId: testUser._id, resourceId: app.id, permBits: VIEW | EDIT });
+ req.params.id = app.artifactAppId;
+
+ await canAccessArtifactAppResource({ requiredPermission: EDIT })(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+
+ test('returns 404 for an unknown artifact app id', async () => {
+ req.params.id = 'app_does_not_exist';
+
+ await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(404);
+ expect(res.json).toHaveBeenCalledWith({
+ error: 'Not Found',
+ message: `${ResourceType.ARTIFACT_APP} not found`,
+ });
+ });
+
+ /* A grant on the same ObjectId under a different resourceType must not
+ * leak across: ACL rows are keyed by (resourceType, resourceId). */
+ test('ignores a grant recorded under a different resourceType', async () => {
+ const { app } = await publishApp(testUser._id);
+ await AclEntry.create({
+ principalType: PrincipalType.USER,
+ principalId: testUser._id,
+ principalModel: PrincipalModel.USER,
+ resourceType: ResourceType.AGENT,
+ resourceId: app.id,
+ permBits: OWNER_BITS,
+ grantedBy: testUser._id,
+ });
+ req.params.id = app.artifactAppId;
+
+ await canAccessArtifactAppResource({ requiredPermission: VIEW })(req, res, next);
+
+ expect(next).not.toHaveBeenCalled();
+ expect(res.status).toHaveBeenCalledWith(403);
+ });
+
+ test('reads the app id from a custom resourceIdParam', async () => {
+ const { app } = await publishApp(testUser._id);
+ await grant({ principalId: testUser._id, resourceId: app.id, permBits: OWNER_BITS });
+ req.params.appId = app.artifactAppId;
+
+ await canAccessArtifactAppResource({
+ requiredPermission: VIEW,
+ resourceIdParam: 'appId',
+ })(req, res, next);
+
+ expect(next).toHaveBeenCalled();
+ expect(res.status).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js
index 31548411bac..6af901fade1 100644
--- a/api/server/middleware/accessResources/index.js
+++ b/api/server/middleware/accessResources/index.js
@@ -5,6 +5,7 @@ const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup');
const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource');
const { canAccessMCPServerResource } = require('./canAccessMCPServerResource');
const { canAccessSkillResource } = require('./canAccessSkillResource');
+const { canAccessArtifactAppResource } = require('./canAccessArtifactAppResource');
module.exports = {
canAccessResource,
@@ -14,4 +15,5 @@ module.exports = {
canAccessPromptGroupResource,
canAccessMCPServerResource,
canAccessSkillResource,
+ canAccessArtifactAppResource,
};
diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js
index 96aa8c8a3e0..114ab821ae4 100644
--- a/api/server/routes/accessPermissions.js
+++ b/api/server/routes/accessPermissions.js
@@ -6,7 +6,11 @@ const {
ResourceType,
PermissionBits,
} = require('librechat-data-provider');
-const { createAgentAdminPermissionAccess, isAgentPermissionsAdmin } = require('@librechat/api');
+const {
+ createAgentAdminPermissionAccess,
+ createArtifactAppSharingPolicy,
+ isAgentPermissionsAdmin,
+} = require('@librechat/api');
const {
getUserEffectivePermissions,
getAllEffectivePermissions,
@@ -25,6 +29,9 @@ const db = require('~/models');
const { findMCPServerByObjectId, getSkillById } = db;
const router = express.Router();
+const enforceArtifactAppSharingPolicy = createArtifactAppSharingPolicy({
+ getArtifactAppsByIds: db.getArtifactAppsByIds,
+});
// Apply common middleware
router.use(requireJwtAuth);
@@ -99,6 +106,12 @@ const checkResourcePermissionAccess = (requiredPermission) =>
requiredPermission,
resourceIdParam: 'resourceId',
});
+ } else if (resourceType === ResourceType.ARTIFACT_APP) {
+ middleware = canAccessResource({
+ resourceType: ResourceType.ARTIFACT_APP,
+ requiredPermission,
+ resourceIdParam: 'resourceId',
+ });
} else if (resourceType === ResourceType.SHARED_LINK) {
middleware = canAccessResource({
resourceType: ResourceType.SHARED_LINK,
@@ -197,6 +210,7 @@ router.put(
checkResourcePermissionAccess(PermissionBits.SHARE),
checkShareAccessUnlessAgentAdmin,
checkSharePublicAccess,
+ enforceArtifactAppSharingPolicy,
rejectSharedLinkOwnerPermissionChanges,
updateResourcePermissions,
);
diff --git a/api/server/routes/accessPermissions.sharePolicy.test.js b/api/server/routes/accessPermissions.sharePolicy.test.js
index ed17a044529..ade6192c89f 100644
--- a/api/server/routes/accessPermissions.sharePolicy.test.js
+++ b/api/server/routes/accessPermissions.sharePolicy.test.js
@@ -2,6 +2,7 @@ jest.mock('~/models', () => ({
getRoleByName: jest.fn(),
findMCPServerByObjectId: jest.fn(),
getSkillById: jest.fn(),
+ getArtifactAppsByIds: jest.fn(),
}));
jest.mock('~/server/middleware', () => ({
@@ -45,7 +46,7 @@ const { updateResourcePermissions } = require('~/server/controllers/PermissionsC
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { canAccessResource } = require('~/server/middleware');
const accessPermissionsRouter = require('./accessPermissions');
-const { getRoleByName } = require('~/models');
+const { getRoleByName, getArtifactAppsByIds } = require('~/models');
describe('Access permissions share policy', () => {
let app;
@@ -65,6 +66,17 @@ describe('Access permissions share policy', () => {
resourceIdParam: 'resourceId',
},
},
+ {
+ label: 'artifact',
+ resourceType: ResourceType.ARTIFACT_APP,
+ permissionType: PermissionTypes.ARTIFACTS,
+ accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER,
+ middlewareOptions: {
+ resourceType: ResourceType.ARTIFACT_APP,
+ requiredPermission: PermissionBits.SHARE,
+ resourceIdParam: 'resourceId',
+ },
+ },
{
label: 'prompt group',
resourceType: ResourceType.PROMPTGROUP,
@@ -130,6 +142,17 @@ describe('Access permissions share policy', () => {
});
};
+ const allowArtifactSharing = () => {
+ getRoleByName.mockResolvedValue({
+ permissions: {
+ [PermissionTypes.ARTIFACTS]: {
+ [Permissions.SHARE]: true,
+ [Permissions.SHARE_PUBLIC]: true,
+ },
+ },
+ });
+ };
+
const mockSharedLinkOwner = (ownerId = 'owner-user') => {
mockSharedLinkFindById.mockReturnValue({
lean: jest.fn().mockResolvedValue({ user: ownerId }),
@@ -317,4 +340,88 @@ describe('Access permissions share policy', () => {
expect(response.status).toBe(200);
expect(updateResourcePermissions).toHaveBeenCalledTimes(1);
});
+
+ it('blocks direct Artifact App editor grants', async () => {
+ allowArtifactSharing();
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`)
+ .send({
+ updated: [
+ {
+ type: PrincipalType.USER,
+ id: 'target-user',
+ accessRoleId: AccessRoleIds.ARTIFACT_APP_EDITOR,
+ },
+ ],
+ removed: [],
+ public: false,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Artifact Apps can only be shared with viewer access');
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ });
+
+ it('blocks removing the canonical Artifact App owner', async () => {
+ allowArtifactSharing();
+ getArtifactAppsByIds.mockResolvedValue([{ createdBy: 'artifact-owner' }]);
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`)
+ .send({
+ updated: [],
+ removed: [{ type: PrincipalType.USER, id: 'artifact-owner' }],
+ public: false,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Artifact App owner permissions cannot be changed');
+ expect(getArtifactAppsByIds).toHaveBeenCalledWith([resourceId]);
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ });
+
+ it('allows Artifact App viewer grants for non-owners', async () => {
+ allowArtifactSharing();
+ getArtifactAppsByIds.mockResolvedValue([{ createdBy: 'artifact-owner' }]);
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`)
+ .send({
+ updated: [
+ {
+ type: PrincipalType.USER,
+ id: 'target-user',
+ accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER,
+ },
+ ],
+ removed: [],
+ public: false,
+ });
+
+ expect(response.status).toBe(200);
+ expect(updateResourcePermissions).toHaveBeenCalledTimes(1);
+ });
+
+ it('blocks Artifact App role-wide grants even when public sharing is allowed', async () => {
+ allowArtifactSharing();
+
+ const response = await request(app)
+ .put(`/api/permissions/${ResourceType.ARTIFACT_APP}/${resourceId}`)
+ .send({
+ updated: [
+ {
+ type: PrincipalType.ROLE,
+ id: 'USER',
+ accessRoleId: AccessRoleIds.ARTIFACT_APP_VIEWER,
+ },
+ ],
+ removed: [],
+ public: false,
+ });
+
+ expect(response.status).toBe(400);
+ expect(response.body.message).toBe('Artifact Apps cannot be shared with roles');
+ expect(updateResourcePermissions).not.toHaveBeenCalled();
+ });
});
diff --git a/api/server/routes/artifactApps.js b/api/server/routes/artifactApps.js
new file mode 100644
index 00000000000..ebeb53954ff
--- /dev/null
+++ b/api/server/routes/artifactApps.js
@@ -0,0 +1,143 @@
+const express = require('express');
+const {
+ createArtifactAppHandlers,
+ createSourceConversationExistsCheck,
+ generateCheckAccess,
+} = require('@librechat/api');
+const { ResourceCapabilityMap } = require('@librechat/data-schemas');
+const {
+ Permissions,
+ PermissionBits,
+ PermissionTypes,
+ ResourceType,
+} = require('librechat-data-provider');
+const {
+ createArtifactAppWithVersion,
+ syncArtifactAppWithVersion,
+ restoreArtifactAppWithVersion,
+ getArtifactAppByAppId,
+ getArtifactAppBySource,
+ getDeletedArtifactAppBySource,
+ listArtifactApps,
+ getArtifactAppsByIds,
+ updateArtifactApp,
+ deleteArtifactApp,
+ prepareArtifactAppDeletion,
+ finalizeArtifactAppDeletion,
+ getArtifactVersion,
+ listArtifactVersions,
+ releaseArtifactVersion,
+ activateArtifactVersion,
+ withdrawArtifactVersion,
+ recordAuditEntry,
+ getRoleByName,
+ getConvo,
+} = require('~/models');
+const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware');
+const { hasCapability } = require('~/server/middleware/roles/capabilities');
+const {
+ getResourcePermissionsMap,
+ grantPermission,
+ removeAllPermissions,
+} = require('~/server/services/PermissionService');
+const configMiddleware = require('~/server/middleware/config/app');
+
+const router = express.Router();
+
+router.use(requireJwtAuth);
+router.use(configMiddleware);
+
+const checkArtifactAccess = generateCheckAccess({
+ permissionType: PermissionTypes.ARTIFACTS,
+ permissions: [Permissions.USE],
+ getRoleByName,
+});
+const checkArtifactCreate = generateCheckAccess({
+ permissionType: PermissionTypes.ARTIFACTS,
+ permissions: [Permissions.USE, Permissions.CREATE],
+ getRoleByName,
+});
+
+const handlers = createArtifactAppHandlers({
+ createArtifactAppWithVersion,
+ syncArtifactAppWithVersion,
+ restoreArtifactAppWithVersion,
+ getArtifactAppByAppId,
+ getArtifactAppBySource,
+ getDeletedArtifactAppBySource,
+ listArtifactApps,
+ getArtifactAppsByIds,
+ updateArtifactApp,
+ deleteArtifactApp,
+ prepareArtifactAppDeletion,
+ finalizeArtifactAppDeletion,
+ getArtifactVersion,
+ listArtifactVersions,
+ releaseArtifactVersion,
+ activateArtifactVersion,
+ withdrawArtifactVersion,
+ getResourcePermissionsMap,
+ grantPermission,
+ removeAllPermissions,
+ hasResourceManagementCapability: (user) =>
+ hasCapability(user, ResourceCapabilityMap[ResourceType.ARTIFACT_APP]),
+ recordAuditEntry,
+ sourceConversationExists: createSourceConversationExistsCheck(getConvo),
+ getConfig: (req) => req.config?.artifactApps,
+});
+
+// Collection
+router.get('/', checkArtifactAccess, handlers.list);
+router.post('/', checkArtifactCreate, handlers.publish);
+router.post('/sync', checkArtifactCreate, handlers.sync);
+router.post('/restore', checkArtifactCreate, handlers.restore);
+router.get('/source', checkArtifactAccess, handlers.getBySource);
+
+// Single app
+router.get(
+ '/:id',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
+ handlers.get,
+);
+router.patch(
+ '/:id',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
+ handlers.update,
+);
+router.delete('/:id', checkArtifactAccess, handlers.remove);
+
+// Versions
+router.get(
+ '/:id/versions',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
+ handlers.listVersions,
+);
+router.get(
+ '/:id/versions/:versionId',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
+ handlers.getVersion,
+);
+router.post(
+ '/:id/versions/:versionId/release',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
+ handlers.releaseVersion,
+);
+router.post(
+ '/:id/versions/:versionId/activate',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
+ handlers.activateVersion,
+);
+router.post(
+ '/:id/versions/:versionId/withdraw',
+ checkArtifactAccess,
+ canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
+ handlers.withdrawVersion,
+);
+
+module.exports = router;
diff --git a/api/server/routes/config.js b/api/server/routes/config.js
index fd0adb57ae9..58c9b3353b2 100644
--- a/api/server/routes/config.js
+++ b/api/server/routes/config.js
@@ -307,6 +307,7 @@ router.get('/', async function (req, res) {
...buildPostLoginPayload(appConfig),
...getConversationTitleCapabilities(appConfig?.interfaceConfig),
conversationListLimits,
+ artifactApps: appConfig?.artifactApps,
sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig),
socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins,
projects: appConfig?.projects,
diff --git a/api/server/routes/index.js b/api/server/routes/index.js
index 14afa6f2691..861346339a4 100644
--- a/api/server/routes/index.js
+++ b/api/server/routes/index.js
@@ -21,6 +21,7 @@ const projects = require('./projects');
const prompts = require('./prompts');
const schedules = require('./schedules');
const skills = require('./skills');
+const artifactApps = require('./artifactApps');
const balance = require('./balance');
const actions = require('./actions');
const apiKeys = require('./apiKeys');
@@ -80,6 +81,7 @@ module.exports = {
projects,
schedules,
skills,
+ artifactApps,
actions,
presets,
balance,
diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js
index ec4c4fea116..73d8c97ecca 100644
--- a/api/server/routes/roles.js
+++ b/api/server/routes/roles.js
@@ -5,6 +5,7 @@ const {
roleDefaults,
PermissionTypes,
agentPermissionsSchema,
+ artifactPermissionsSchema,
promptPermissionsSchema,
memoryPermissionsSchema,
mcpServersPermissionsSchema,
@@ -36,6 +37,11 @@ const permissionConfigs = {
permissionType: PermissionTypes.AGENTS,
errorMessage: 'Invalid agent permissions.',
},
+ artifacts: {
+ schema: artifactPermissionsSchema,
+ permissionType: PermissionTypes.ARTIFACTS,
+ errorMessage: 'Invalid artifact permissions.',
+ },
memories: {
schema: memoryPermissionsSchema,
permissionType: PermissionTypes.MEMORIES,
@@ -165,6 +171,9 @@ router.put('/:roleName/prompts', manageRoles, createPermissionUpdateHandler('pro
*/
router.put('/:roleName/agents', manageRoles, createPermissionUpdateHandler('agents'));
+/** Update Artifact Catalog permissions for a specific role. */
+router.put('/:roleName/artifacts', manageRoles, createPermissionUpdateHandler('artifacts'));
+
/**
* PUT /api/roles/:roleName/memories
* Update memory permissions for a specific role
diff --git a/api/server/services/start/migration.js b/api/server/services/start/migration.js
index 70f8300e08d..607fd41dff4 100644
--- a/api/server/services/start/migration.js
+++ b/api/server/services/start/migration.js
@@ -1,5 +1,5 @@
const mongoose = require('mongoose');
-const { logger } = require('@librechat/data-schemas');
+const { logger, ensureArtifactAppIndexes } = require('@librechat/data-schemas');
const {
logAgentMigrationWarning,
logPromptMigrationWarning,
@@ -13,6 +13,7 @@ const { findRoleByIdentifier } = require('~/models');
* This runs at the end to ensure all systems are initialized
*/
async function checkMigrations() {
+ await ensureArtifactAppIndexes(mongoose.connection);
try {
const agentMigrationResult = await checkAgentPermissionsMigration({
mongoose,
diff --git a/client/package.json b/client/package.json
index 6831d650cdf..366b533ac23 100644
--- a/client/package.json
+++ b/client/package.json
@@ -158,6 +158,7 @@
"babel-plugin-transform-import-meta": "^2.3.2",
"babel-plugin-transform-vite-meta-env": "^1.0.3",
"eslint-plugin-jest": "^29.1.0",
+ "fake-indexeddb": "^6.2.5",
"identity-obj-proxy": "^3.0.0",
"jest": "^30.2.0",
"jest-canvas-mock": "^2.5.2",
diff --git a/client/src/Providers/ArtifactsContext.tsx b/client/src/Providers/ArtifactsContext.tsx
index f2bb28fabf1..bbb5d378ee4 100644
--- a/client/src/Providers/ArtifactsContext.tsx
+++ b/client/src/Providers/ArtifactsContext.tsx
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
+import { Constants } from 'librechat-data-provider';
import { useLatestMessage } from '~/hooks/Messages/useLatestMessage';
import { getLatestText } from '~/utils';
import store from '~/store';
@@ -8,6 +9,7 @@ export interface ArtifactsContextValue {
isSubmitting: boolean;
latestMessageId: string | null;
latestMessageText: string;
+ latestMessageError: boolean;
conversationId: string | null;
/** Whether this host offers opening the pane in its own window. The pane
* consumes the deployment's `interface.artifactUndocking` rather than
@@ -37,14 +39,31 @@ export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) {
return getLatestText(latestMessage);
}, [latestMessage]);
+ /**
+ * A tool-call-limit pause also sets `unfinished: true` even though nothing
+ * failed or was cancelled — its distinct `finish_reason` is what excludes
+ * it here, so a paused-but-valid generation still registers its artifacts.
+ */
+ const latestMessageError =
+ latestMessage?.error === true ||
+ (latestMessage?.unfinished === true &&
+ latestMessage.finish_reason !== Constants.TOOL_CALL_LIMIT_FINISH_REASON);
+
const defaultContextValue = useMemo>(
() => ({
isSubmitting,
conversationId: conversationId ?? null,
latestMessageText: chatLatestMessageText,
latestMessageId: latestMessage?.messageId ?? null,
+ latestMessageError,
}),
- [isSubmitting, chatLatestMessageText, latestMessage?.messageId, conversationId],
+ [
+ isSubmitting,
+ chatLatestMessageText,
+ latestMessage?.messageId,
+ latestMessageError,
+ conversationId,
+ ],
);
const contextValue = useMemo(
diff --git a/client/src/Providers/__tests__/ArtifactsContext.spec.tsx b/client/src/Providers/__tests__/ArtifactsContext.spec.tsx
new file mode 100644
index 00000000000..95da5d1b3f2
--- /dev/null
+++ b/client/src/Providers/__tests__/ArtifactsContext.spec.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { RecoilRoot } from 'recoil';
+import { Constants } from 'librechat-data-provider';
+import { render, screen } from '@testing-library/react';
+import type { TMessage } from 'librechat-data-provider';
+import { ArtifactsProvider, useArtifactsContext } from '~/Providers/ArtifactsContext';
+
+const mockUseLatestMessage = jest.fn();
+
+jest.mock('~/hooks/Messages/useLatestMessage', () => ({
+ useLatestMessage: () => mockUseLatestMessage(),
+}));
+
+function TestConsumer() {
+ const { latestMessageError } = useArtifactsContext();
+ return {String(latestMessageError)};
+}
+
+function renderWithMessage(message: Partial | null) {
+ mockUseLatestMessage.mockReturnValue(message);
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('ArtifactsProvider latestMessageError', () => {
+ it('is false for a normally completed message', () => {
+ renderWithMessage({ messageId: 'message-1', error: false, unfinished: false });
+ expect(screen.getByTestId('latest-message-error')).toHaveTextContent('false');
+ });
+
+ it('is true for an errored message', () => {
+ renderWithMessage({ messageId: 'message-1', error: true, unfinished: false });
+ expect(screen.getByTestId('latest-message-error')).toHaveTextContent('true');
+ });
+
+ it('is true for an aborted message (unfinished with no finish_reason)', () => {
+ renderWithMessage({ messageId: 'message-1', error: false, unfinished: true });
+ expect(screen.getByTestId('latest-message-error')).toHaveTextContent('true');
+ });
+
+ it('is false for a tool-call-limit pause even though the message is unfinished', () => {
+ renderWithMessage({
+ messageId: 'message-1',
+ error: false,
+ unfinished: true,
+ finish_reason: String(Constants.TOOL_CALL_LIMIT_FINISH_REASON),
+ });
+ expect(screen.getByTestId('latest-message-error')).toHaveTextContent('false');
+ });
+});
diff --git a/client/src/common/artifacts.ts b/client/src/common/artifacts.ts
index 09811fda24e..61d4219bead 100644
--- a/client/src/common/artifacts.ts
+++ b/client/src/common/artifacts.ts
@@ -1,3 +1,5 @@
+import type { ArtifactPreview } from 'librechat-data-provider';
+
export interface CodeBlock {
id: string;
language: string;
@@ -33,6 +35,7 @@ export interface Artifact {
content?: string;
title?: string;
type?: string;
+ preview?: ArtifactPreview;
download?: ArtifactDownload;
}
diff --git a/client/src/components/ArtifactApps/AppRenderer.tsx b/client/src/components/ArtifactApps/AppRenderer.tsx
new file mode 100644
index 00000000000..6f686bc8423
--- /dev/null
+++ b/client/src/components/ArtifactApps/AppRenderer.tsx
@@ -0,0 +1,42 @@
+import { useRef } from 'react';
+import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
+import type { TArtifactVersion } from 'librechat-data-provider';
+import type { Artifact } from '~/common';
+import { ArtifactPreview } from '~/components/Artifacts/ArtifactPreview';
+import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
+import { useGetStartupConfig } from '~/data-provider';
+import { getArtifactMimeType } from './runtime';
+
+export default function AppRenderer({
+ title,
+ version,
+}: {
+ title: string;
+ version: TArtifactVersion;
+}) {
+ const previewRef = useRef(null as unknown as SandpackPreviewRef);
+ const { data: startupConfig } = useGetStartupConfig();
+
+ const artifact: Artifact = {
+ id: version.artifactVersionId,
+ lastUpdateTime: Date.now(),
+ type: getArtifactMimeType(version.artifactType),
+ content: version.sourceSnapshot,
+ title,
+ };
+
+ const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
+
+ return (
+
+ );
+}
diff --git a/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx
new file mode 100644
index 00000000000..ab7a36e3fec
--- /dev/null
+++ b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.spec.tsx
@@ -0,0 +1,106 @@
+import React from 'react';
+import userEvent from '@testing-library/user-event';
+import { render, screen } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import ArtifactAppsAdminSettings from './ArtifactAppsAdminSettings';
+
+let mockRole = 'ADMIN';
+const mockDefaultValues = {};
+
+jest.mock('@librechat/client', () => ({
+ ...jest.requireActual('@librechat/client'),
+ useToastContext: () => ({ showToast: jest.fn() }),
+}));
+
+jest.mock('~/components/ui', () => {
+ const ReactModule = jest.requireActual('react') as typeof React;
+ const labels: Record = {
+ com_ui_artifacts_allow_use: 'Allow access to the artifact catalog',
+ com_ui_artifacts_allow_create: 'Automatically save created artifacts',
+ com_ui_artifacts_allow_share: 'Allow sharing artifacts with people and groups',
+ com_ui_artifacts_allow_share_public: 'Allow sharing artifacts with everyone',
+ };
+ return {
+ AdminSettingsDialog: ({
+ trigger,
+ permissions,
+ }: {
+ trigger: React.ReactElement;
+ permissions: Array<{ labelKey: string }>;
+ }) => {
+ const [open, setOpen] = ReactModule.useState(false);
+ if (mockRole !== 'ADMIN') {
+ return null;
+ }
+ const heading = ['Admin Settings', 'Artifacts'].join(' - ');
+ return (
+ <>
+ {ReactModule.cloneElement(trigger, { onClick: () => setOpen(true) })}
+ {open && (
+
+
{heading}
+ {permissions.map(({ labelKey }) => (
+ {labels[labelKey]}
+ ))}
+
+ )}
+ >
+ );
+ },
+ };
+});
+
+jest.mock('~/hooks', () => ({
+ useAuthContext: () => ({ user: { role: mockRole } }),
+ useLocalize: () => (key: string, values?: { section?: string }) => {
+ const translations: Record = {
+ com_ui_admin_settings: 'Admin Settings',
+ com_ui_artifact_apps: 'Artifacts',
+ com_ui_artifacts_allow_use: 'Allow access to the artifact catalog',
+ com_ui_artifacts_allow_create: 'Automatically save created artifacts',
+ com_ui_artifacts_allow_share: 'Allow sharing artifacts with people and groups',
+ com_ui_artifacts_allow_share_public: 'Allow sharing artifacts with everyone',
+ };
+ if (key === 'com_ui_admin_settings_section') {
+ return `Admin Settings - ${values?.section}`;
+ }
+ return translations[key] ?? key;
+ },
+ useRoleSelector: () => ({
+ selectedRole: 'ADMIN',
+ isSelectedCustomRole: false,
+ isCustomRoleLoading: false,
+ isCustomRoleError: false,
+ defaultValues: mockDefaultValues,
+ roleDropdownItems: [],
+ }),
+}));
+
+jest.mock('~/data-provider', () => ({
+ useUpdateArtifactPermissionsMutation: () => ({ mutate: jest.fn(), isLoading: false }),
+}));
+
+describe('ArtifactAppsAdminSettings', () => {
+ beforeEach(() => {
+ mockRole = 'ADMIN';
+ });
+
+ it('opens artifact permission settings for an administrator', async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole('button', { name: 'Admin Settings' }));
+
+ expect(screen.getByRole('dialog')).toBeInTheDocument();
+ expect(screen.getByText('Admin Settings - Artifacts')).toBeInTheDocument();
+ expect(screen.getByText('Allow access to the artifact catalog')).toBeInTheDocument();
+ expect(screen.getByText('Allow sharing artifacts with everyone')).toBeInTheDocument();
+ });
+
+ it('does not show the administration action to a non-administrator', () => {
+ mockRole = 'USER';
+ render();
+
+ expect(screen.queryByRole('button', { name: 'Admin Settings' })).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx
new file mode 100644
index 00000000000..b6ba51a142f
--- /dev/null
+++ b/client/src/components/ArtifactApps/ArtifactAppsAdminSettings.tsx
@@ -0,0 +1,55 @@
+import { ShieldEllipsis } from 'lucide-react';
+import { Button, useToastContext } from '@librechat/client';
+import { Permissions, PermissionTypes } from 'librechat-data-provider';
+import type { PermissionConfig } from '~/components/ui';
+import { useUpdateArtifactPermissionsMutation } from '~/data-provider';
+import { AdminSettingsDialog } from '~/components/ui';
+import { useLocalize } from '~/hooks';
+
+const permissions: PermissionConfig[] = [
+ { permission: Permissions.USE, labelKey: 'com_ui_artifacts_allow_use' },
+ { permission: Permissions.CREATE, labelKey: 'com_ui_artifacts_allow_create' },
+ { permission: Permissions.SHARE, labelKey: 'com_ui_artifacts_allow_share' },
+ { permission: Permissions.SHARE_PUBLIC, labelKey: 'com_ui_artifacts_allow_share_public' },
+];
+
+const ArtifactAppsAdminSettings = ({ compact = false }: { compact?: boolean }) => {
+ const localize = useLocalize();
+ const { showToast } = useToastContext();
+ const mutation = useUpdateArtifactPermissionsMutation({
+ onSuccess: () => showToast({ status: 'success', message: localize('com_ui_saved') }),
+ onError: () =>
+ showToast({ status: 'error', message: localize('com_ui_error_save_admin_settings') }),
+ });
+
+ const trigger = (
+
+ );
+
+ return (
+
+ );
+};
+
+export default ArtifactAppsAdminSettings;
diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx
new file mode 100644
index 00000000000..9a5b1ed73d8
--- /dev/null
+++ b/client/src/components/ArtifactApps/ArtifactAppsList.spec.tsx
@@ -0,0 +1,781 @@
+import React from 'react';
+import { BrowserRouter } from 'react-router-dom';
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import '@testing-library/jest-dom';
+import { useSetAtom } from 'jotai';
+import { PermissionBits } from 'librechat-data-provider';
+import type { TArtifactApp } from 'librechat-data-provider';
+import type { MenuItemProps } from '~/common';
+import {
+ useDeleteArtifactAppMutation,
+ useListArtifactAppsQuery,
+ useWithdrawArtifactVersionMutation,
+} from '~/data-provider';
+import ArtifactAppsList from './ArtifactAppsList';
+
+const mockShowToast = jest.fn();
+const mockUseAuthContext = jest.fn(() => ({
+ user: { id: 'user-1', role: 'USER', tenantId: undefined as string | undefined },
+}));
+
+const getPinnedStorageKey = (userId: string, tenantId = '__default__') =>
+ `librechat.pinnedArtifactApps:${tenantId}:${userId}`;
+const getViewedStorageKey = (userId: string, tenantId = '__default__') =>
+ `librechat.viewedArtifactApps:${tenantId}:${userId}`;
+
+jest.mock('~/data-provider', () => ({
+ useDeleteArtifactAppMutation: jest.fn(),
+ useListArtifactAppsQuery: jest.fn(),
+ useWithdrawArtifactVersionMutation: jest.fn(),
+}));
+
+jest.mock('jotai', () => ({
+ ...jest.requireActual('jotai'),
+ useSetAtom: jest.fn(),
+}));
+
+jest.mock('~/hooks', () => ({
+ useAuthContext: () => mockUseAuthContext(),
+ useDebounce: (value: string) => value,
+ useHasAccess: () => true,
+ useLocalize: () => (key: string, values?: Record) => {
+ const translations: Record = {
+ com_ui_artifact_apps: 'Artifacts',
+ com_ui_artifact_apps_description: 'Artifacts published from your conversations.',
+ com_ui_artifact_apps_search_placeholder: 'Search artifacts',
+ com_ui_artifact_apps_search_aria: 'Search artifacts',
+ com_ui_artifact_apps_clear_search: 'Clear artifact search',
+ com_ui_artifact_apps_no_results: 'No artifacts found',
+ com_ui_artifact_apps_no_results_hint: 'Try a different name, description, category, or tag.',
+ com_ui_artifact_apps_load_error: "Couldn't load artifacts.",
+ com_ui_artifact_apps_refresh_error:
+ "Couldn't update the artifact catalog. Your loaded artifacts are still available.",
+ com_ui_artifact_catalog_filters: 'Artifact catalog filters',
+ com_ui_artifact_scope_personal: 'Personal',
+ com_ui_artifact_scope_shared: 'Shared',
+ com_ui_artifact_scope_all: 'All',
+ com_ui_artifact_shared_with_you: 'Shared with you',
+ com_ui_artifact_link_copied: 'Artifact link copied',
+ com_ui_artifact_delete: 'Delete artifact?',
+ com_ui_artifact_delete_confirm: `Delete "${values?.[0] ?? ''}"? This action cannot be undone.`,
+ com_ui_artifact_delete_error: "Couldn't delete the artifact. Try again.",
+ com_ui_artifact_delete_success: 'Artifact deleted',
+ com_ui_artifact_withdraw: 'Withdraw active version?',
+ com_ui_artifact_withdraw_confirm: `Withdraw the active version of "${values?.[0] ?? ''}"?`,
+ com_ui_artifact_withdraw_error: "Couldn't withdraw the active version. Try again.",
+ com_ui_artifact_withdraw_success: 'Active version withdrawn',
+ com_ui_artifact_apps_view_mode: 'Artifact view mode',
+ com_ui_artifact_apps_list_view: 'List view',
+ com_ui_artifact_apps_grid_view: 'Grid view',
+ com_ui_artifact_app_preview: 'Artifact preview',
+ com_ui_artifact_app_preview_unavailable: 'Preview unavailable',
+ com_ui_artifact_activity_just_now: 'just now',
+ com_ui_copy_failed: 'Failed to copy to clipboard',
+ com_ui_copy_link: 'Copy link',
+ com_ui_delete: 'Delete',
+ com_ui_withdraw: 'Withdraw',
+ com_ui_share: 'Share',
+ com_ui_options: 'options',
+ com_ui_pin: 'Pin',
+ com_ui_pinned: 'Pinned',
+ com_ui_unpin: 'Unpin',
+ com_ui_unpinned: 'Unpinned',
+ com_ui_retry: 'Retry',
+ };
+ if (key === 'com_ui_artifact_app_version_number') {
+ return `v${values?.[0] ?? ''}`;
+ }
+ if (key === 'com_ui_artifact_activity_edited') {
+ return `edited ${values?.[0] ?? ''}`;
+ }
+ if (key === 'com_ui_artifact_activity_viewed') {
+ return `viewed ${values?.[0] ?? ''}`;
+ }
+ if (key === 'com_ui_artifact_activity_minutes_ago') {
+ return `${values?.[0] ?? ''}m ago`;
+ }
+ if (key === 'com_ui_artifact_activity_hours_ago') {
+ return `${values?.[0] ?? ''}h ago`;
+ }
+ return translations[key] ?? key;
+ },
+}));
+
+jest.mock('@librechat/client', () => {
+ const ReactModule = jest.requireActual('react');
+
+ return {
+ ...jest.requireActual('@librechat/client'),
+ DropdownPopup: ({ trigger, items }: { trigger: React.ReactNode; items: MenuItemProps[] }) => {
+ const label = ReactModule.isValidElement<{ 'aria-label'?: string }>(trigger)
+ ? trigger.props['aria-label']
+ : undefined;
+
+ return (
+
+
+ {items
+ .filter((item) => item.show !== false)
+ .map((item) =>
+ item.separate ? (
+
+ ) : (
+
+ ),
+ )}
+
+ );
+ },
+ useMediaQuery: () => false,
+ useToastContext: () => ({ showToast: mockShowToast }),
+ };
+});
+
+jest.mock('~/components/Chat/Menus/OpenSidebar', () => () => null);
+jest.mock('./ArtifactAppsAdminSettings', () => () => );
+jest.mock('./Thumbnail', () => ({ app }: { app: TArtifactApp }) => (
+ {app.preview?.imageUrl}
+));
+
+jest.mock('~/components/Sharing', () => ({
+ GenericGrantAccessDialog: ({
+ resourceId,
+ children,
+ }: {
+ resourceId?: string;
+ children?: React.ReactNode;
+ }) => {children}
,
+}));
+
+const mockUseListArtifactAppsQuery = jest.mocked(useListArtifactAppsQuery);
+const mockUseDeleteArtifactAppMutation = jest.mocked(useDeleteArtifactAppMutation);
+const mockUseWithdrawArtifactVersionMutation = jest.mocked(useWithdrawArtifactVersionMutation);
+const mockSetArtifactNavigationRequest = jest.fn();
+const mockDeleteArtifact = jest.fn();
+const mockWithdrawVersion = jest.fn();
+
+type ArtifactAppsListQueryResult = ReturnType;
+type ArtifactAppsListSuccessResult = Extract;
+
+function makeListQueryResult(
+ overrides: Partial = {},
+): ArtifactAppsListSuccessResult {
+ return {
+ data: { pages: [{ apps, has_more: false, after: null }], pageParams: [undefined] },
+ dataUpdatedAt: 0,
+ error: null,
+ errorUpdateCount: 0,
+ errorUpdatedAt: 0,
+ failureCount: 0,
+ failureReason: null,
+ fetchNextPage: jest.fn(),
+ fetchPreviousPage: jest.fn(),
+ fetchStatus: 'idle',
+ hasNextPage: false,
+ hasPreviousPage: false,
+ isError: false,
+ isFetched: true,
+ isFetchedAfterMount: true,
+ isFetching: false,
+ isFetchingNextPage: false,
+ isFetchingPreviousPage: false,
+ isInitialLoading: false,
+ isLoading: false,
+ isLoadingError: false,
+ isPaused: false,
+ isPlaceholderData: false,
+ isPreviousData: false,
+ isRefetchError: false,
+ isRefetching: false,
+ isStale: false,
+ isSuccess: true,
+ refetch: jest.fn(),
+ remove: jest.fn(),
+ status: 'success',
+ ...overrides,
+ };
+}
+
+const apps = [
+ {
+ id: 'db-quarterly-report',
+ artifactAppId: 'quarterly-report',
+ createdBy: 'user-1',
+ title: 'Quarterly Report',
+ description: 'Revenue summary',
+ category: 'Finance',
+ tags: ['forecast'],
+ preview: {
+ type: 'image',
+ imageUrl:
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+ alt: 'Preview of Quarterly Report',
+ },
+ latestVersionNumber: 2,
+ activeVersionId: 'quarterly-version-2',
+ status: 'published',
+ visibility: 'private',
+ createdAt: '2026-08-20T09:00:00Z',
+ updatedAt: '2026-08-20T10:00:00Z',
+ sourceMetadata: {
+ conversationId: 'conversation-1',
+ messageId: 'message-2',
+ originalArtifactId: 'artifact-revision-2',
+ sourceKey: 'identifier:quarterly-report',
+ },
+ },
+ {
+ id: 'db-team-planner',
+ artifactAppId: 'team-planner',
+ createdBy: 'user-2',
+ title: 'Team Planner',
+ description: 'Coordinate delivery work',
+ category: 'Planning',
+ tags: ['schedule'],
+ latestVersionNumber: 1,
+ status: 'draft',
+ visibility: 'tenant',
+ createdAt: '2026-08-19T08:00:00Z',
+ updatedAt: '2026-08-19T09:00:00Z',
+ },
+] as TArtifactApp[];
+
+describe('ArtifactAppsList', () => {
+ beforeEach(() => {
+ jest.useFakeTimers();
+ jest.setSystemTime(new Date('2026-08-20T12:00:00Z'));
+ jest.mocked(useSetAtom).mockReturnValue(mockSetArtifactNavigationRequest);
+ window.history.replaceState({}, '', '/apps');
+ window.localStorage.clear();
+ mockShowToast.mockClear();
+ mockUseAuthContext.mockReturnValue({
+ user: { id: 'user-1', role: 'USER', tenantId: undefined },
+ });
+ mockUseListArtifactAppsQuery.mockReturnValue(makeListQueryResult());
+ mockUseDeleteArtifactAppMutation.mockReturnValue({
+ mutate: mockDeleteArtifact,
+ isLoading: false,
+ } as unknown as ReturnType);
+ mockDeleteArtifact.mockReset();
+ mockUseWithdrawArtifactVersionMutation.mockReturnValue({
+ mutate: mockWithdrawVersion,
+ isLoading: false,
+ } as unknown as ReturnType);
+ mockWithdrawVersion.mockReset();
+ Object.defineProperty(navigator, 'clipboard', {
+ configurable: true,
+ value: { writeText: jest.fn().mockResolvedValue(undefined) },
+ });
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('renders a Marketplace-style heading, search field, and admin settings action', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('heading', { name: 'Artifacts' })).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: 'Search artifacts' })).toBeInTheDocument();
+ expect(screen.getByRole('tab', { name: 'Personal' })).toHaveAttribute('aria-selected', 'true');
+ expect(screen.getAllByTestId('mock-admin-settings')).not.toHaveLength(0);
+ });
+
+ it('uses stored preview thumbnails in grid view', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'List view' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Grid view' }));
+
+ expect(screen.getByRole('button', { name: 'Grid view' })).toHaveAttribute(
+ 'aria-pressed',
+ 'true',
+ );
+ expect(window.localStorage.getItem('librechat.artifactAppsViewMode')).toBe('grid');
+ expect(screen.getAllByTestId('artifact-thumbnail')[0]).toHaveTextContent(
+ 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
+ );
+ expect(screen.queryByRole('img', { name: 'Preview of Quarterly Report' })).toBeNull();
+ expect(screen.getAllByRole('list')[0]).toHaveClass(
+ 'grid-cols-1',
+ 'md:grid-cols-2',
+ 'lg:grid-cols-3',
+ );
+ expect(screen.getAllByText('Personal')[1].parentElement?.parentElement).toHaveClass('mt-3');
+
+ fireEvent.click(screen.getByRole('button', { name: 'List view' }));
+ expect(screen.queryByTestId('artifact-thumbnail')).toBeNull();
+ });
+
+ it('sends metadata search to the paginated server query', () => {
+ render(
+
+
+ ,
+ );
+
+ const search = screen.getByRole('textbox', { name: 'Search artifacts' });
+ fireEvent.change(search, { target: { value: 'forecast' } });
+
+ expect(mockUseListArtifactAppsQuery).toHaveBeenLastCalledWith('personal', 'forecast');
+ });
+
+ it('requests the selected catalog scope', () => {
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('tab', { name: 'Shared' }));
+ expect(mockUseListArtifactAppsQuery).toHaveBeenLastCalledWith('shared', '');
+ });
+
+ it('loads the next cursor page on request', () => {
+ const fetchNextPage = jest.fn();
+ mockUseListArtifactAppsQuery.mockReturnValue({
+ ...makeListQueryResult(),
+ fetchNextPage,
+ hasNextPage: true,
+ });
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_load_more' }));
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows a retry action when the initial catalog request fails', () => {
+ const refetch = jest.fn();
+ mockUseListArtifactAppsQuery.mockReturnValue({
+ ...makeListQueryResult(),
+ data: undefined,
+ error: new Error('catalog unavailable'),
+ isError: true,
+ isLoading: false,
+ isLoadingError: true,
+ isSuccess: false,
+ refetch,
+ status: 'error',
+ } as ReturnType);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('alert')).toHaveTextContent("Couldn't load artifacts.");
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('preserves loaded catalog content and retry when a later request fails', () => {
+ const refetch = jest.fn();
+ mockUseListArtifactAppsQuery.mockReturnValue({
+ ...makeListQueryResult(),
+ error: new Error('next page unavailable'),
+ isError: true,
+ isLoadingError: false,
+ isRefetchError: true,
+ isSuccess: false,
+ refetch,
+ status: 'error',
+ } as ReturnType);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText('Quarterly Report')).toBeInTheDocument();
+ expect(screen.getByText('Team Planner')).toBeInTheDocument();
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ "Couldn't update the artifact catalog. Your loaded artifacts are still available.",
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('offers retry instead of an empty state when an empty cached catalog refresh fails', () => {
+ const refetch = jest.fn();
+ const data = { pages: [{ apps: [], has_more: false, after: null }], pageParams: [undefined] };
+ mockUseListArtifactAppsQuery.mockReturnValue({
+ ...makeListQueryResult(),
+ data,
+ error: new Error('refresh unavailable'),
+ isError: true,
+ isLoadingError: false,
+ isRefetchError: true,
+ isSuccess: false,
+ refetch,
+ status: 'error',
+ } as ReturnType);
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('alert')).toHaveTextContent(
+ "Couldn't update the artifact catalog. Your loaded artifacts are still available.",
+ );
+ expect(screen.queryByText('No artifacts yet')).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole('button', { name: 'Retry' }));
+ expect(refetch).toHaveBeenCalledTimes(1);
+ });
+
+ it('shows per-artifact actions for pinning and copying links', () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: 'options: Quarterly Report' })).toBeInTheDocument();
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Copy link' })[0]);
+
+ expect(navigator.clipboard.writeText).toHaveBeenCalledWith(
+ new URL('/apps/quarterly-report', window.location.origin).toString(),
+ );
+
+ fireEvent.click(screen.getAllByRole('button', { name: 'Pin' })[1]);
+
+ expect(screen.getAllByRole('button', { name: 'Share' })).toHaveLength(1);
+ expect(screen.queryByTestId('artifact-share-dialog-quarterly-report')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('artifact-share-dialog-team-planner')).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }));
+
+ expect(screen.getByTestId('artifact-share-dialog-quarterly-report')).toBeInTheDocument();
+ expect(screen.queryByTestId('artifact-share-dialog-team-planner')).not.toBeInTheDocument();
+
+ expect(window.localStorage.getItem(getPinnedStorageKey('user-1'))).toBe('["team-planner"]');
+
+ const openButtons = screen.getAllByRole('button', {
+ name: /Quarterly Report|Team Planner/,
+ });
+ expect(openButtons[0]).toHaveTextContent('Team Planner');
+ expect(screen.getByRole('button', { name: 'Unpin' })).toHaveAttribute('aria-pressed', 'true');
+ expect(mockShowToast).toHaveBeenLastCalledWith({ status: 'success', message: 'Pinned' });
+
+ fireEvent.click(screen.getByRole('button', { name: 'Unpin' }));
+
+ expect(mockShowToast).toHaveBeenLastCalledWith({ status: 'info', message: 'Unpinned' });
+ });
+
+ it('deletes an owned artifact after confirmation', () => {
+ mockDeleteArtifact.mockImplementation((_artifactAppId, options) =>
+ options?.onSuccess?.({
+ success: true,
+ }),
+ );
+ window.localStorage.setItem(getPinnedStorageKey('user-1'), '["quarterly-report"]');
+ window.localStorage.setItem(
+ getViewedStorageKey('user-1'),
+ JSON.stringify({ 'quarterly-report': '2026-08-20T11:45:00Z' }),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1);
+ expect(screen.getByRole('button', { name: 'Delete' })).toHaveClass('text-text-destructive');
+ fireEvent.click(screen.getByRole('button', { name: 'Delete' }));
+ expect(screen.getByText('Delete artifact?')).toBeInTheDocument();
+ expect(
+ screen.getByText('Delete "Quarterly Report"? This action cannot be undone.'),
+ ).toBeInTheDocument();
+
+ fireEvent.click(within(screen.getByRole('dialog')).getByText('Delete'));
+
+ expect(mockDeleteArtifact).toHaveBeenCalledWith(
+ 'quarterly-report',
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ expect(window.localStorage.getItem(getPinnedStorageKey('user-1'))).toBe('[]');
+ expect(window.localStorage.getItem(getViewedStorageKey('user-1'))).toBe('{}');
+ expect(mockShowToast).toHaveBeenLastCalledWith({
+ status: 'success',
+ message: 'Artifact deleted',
+ });
+ });
+
+ it('shows delete only when the viewer owns the artifact or has DELETE permission', () => {
+ mockUseListArtifactAppsQuery.mockReturnValue(
+ makeListQueryResult({
+ data: {
+ pages: [
+ {
+ apps: [
+ apps[1],
+ { ...apps[1], artifactAppId: 'deletable', permissionBits: PermissionBits.DELETE },
+ ],
+ has_more: false,
+ after: null,
+ },
+ ],
+ pageParams: [undefined],
+ },
+ }),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getAllByRole('button', { name: 'Delete' })).toHaveLength(1);
+ });
+
+ it('withdraws the active version of an owned artifact after confirmation', () => {
+ mockWithdrawVersion.mockImplementation((_variables, options) => options?.onSuccess?.());
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getAllByRole('button', { name: 'Withdraw' })).toHaveLength(1);
+ fireEvent.click(screen.getByRole('button', { name: 'Withdraw' }));
+ expect(
+ screen.getByText('Withdraw the active version of "Quarterly Report"?'),
+ ).toBeInTheDocument();
+
+ fireEvent.click(within(screen.getByRole('dialog')).getByText('Withdraw'));
+
+ expect(mockWithdrawVersion).toHaveBeenCalledWith(
+ { artifactAppId: 'quarterly-report', versionId: 'quarterly-version-2' },
+ expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }),
+ );
+ expect(mockShowToast).toHaveBeenLastCalledWith({
+ status: 'success',
+ message: 'Active version withdrawn',
+ });
+ });
+
+ it('shows withdraw only when the viewer can manage the version and one is active', () => {
+ mockUseListArtifactAppsQuery.mockReturnValue(
+ makeListQueryResult({
+ data: {
+ pages: [
+ {
+ apps: [
+ apps[1],
+ {
+ ...apps[1],
+ artifactAppId: 'withdrawable',
+ activeVersionId: 'withdrawable-version-1',
+ permissionBits: PermissionBits.EDIT,
+ },
+ ],
+ has_more: false,
+ after: null,
+ },
+ ],
+ pageParams: [undefined],
+ },
+ }),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryAllByRole('button', { name: 'Withdraw' })).toHaveLength(1);
+ });
+
+ it('shows recent activity as ago text and older activity as a date', () => {
+ window.localStorage.setItem(
+ getViewedStorageKey('user-1'),
+ JSON.stringify({ 'team-planner': '2026-08-20T11:45:00Z' }),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByText(/v2 · edited 2h ago/)).toBeInTheDocument();
+ expect(screen.getByText(/v1 · edited Aug 19 · viewed 15m ago/)).toBeInTheDocument();
+ });
+
+ it('rehydrates pinned and viewed activity when the authenticated identity changes', () => {
+ mockUseAuthContext.mockReturnValue({
+ user: { id: 'user-1', role: 'USER', tenantId: 'tenant-1' },
+ });
+ window.localStorage.setItem(getPinnedStorageKey('user-1', 'tenant-1'), '["team-planner"]');
+ window.localStorage.setItem(
+ getViewedStorageKey('user-1', 'tenant-1'),
+ JSON.stringify({ 'team-planner': '2026-08-20T11:45:00Z' }),
+ );
+ window.localStorage.setItem(getPinnedStorageKey('user-2', 'tenant-1'), '["quarterly-report"]');
+ window.localStorage.setItem(
+ getViewedStorageKey('user-2', 'tenant-1'),
+ JSON.stringify({ 'quarterly-report': '2026-08-20T11:30:00Z' }),
+ );
+
+ const renderCatalog = () => (
+
+
+
+ );
+ const { rerender } = render(renderCatalog());
+
+ expect(
+ screen.getAllByRole('button', { name: /Quarterly Report|Team Planner/ })[0],
+ ).toHaveTextContent('Team Planner');
+ expect(screen.getByText(/v1 · edited Aug 19 · viewed 15m ago/)).toBeInTheDocument();
+
+ mockUseAuthContext.mockReturnValue({
+ user: { id: 'user-2', role: 'USER', tenantId: 'tenant-1' },
+ });
+ rerender(renderCatalog());
+
+ expect(
+ screen.getAllByRole('button', { name: /Quarterly Report|Team Planner/ })[0],
+ ).toHaveTextContent('Quarterly Report');
+ expect(screen.queryByText(/viewed 15m ago/)).not.toBeInTheDocument();
+ expect(screen.getByText(/v2 · edited 2h ago · viewed 30m ago/)).toBeInTheDocument();
+ });
+
+ it('keeps cursor pagination available while filtering loaded artifacts', () => {
+ const fetchNextPage = jest.fn();
+ mockUseListArtifactAppsQuery.mockReturnValue({
+ ...makeListQueryResult(),
+ fetchNextPage,
+ hasNextPage: true,
+ });
+ render(
+
+
+ ,
+ );
+
+ fireEvent.change(screen.getByRole('textbox', { name: 'Search artifacts' }), {
+ target: { value: 'artifact on a later page' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'com_ui_load_more' }));
+
+ expect(screen.queryByText('No artifacts found')).not.toBeInTheDocument();
+ expect(fetchNextPage).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens an owned artifact at its latest stored revision', () => {
+ render(
+
+
+ ,
+ );
+
+ const reportButton = screen.getByText('Quarterly Report').closest('button');
+ expect(reportButton).not.toBeNull();
+
+ fireEvent.click(reportButton as HTMLButtonElement);
+
+ const viewed = JSON.parse(
+ window.localStorage.getItem(getViewedStorageKey('user-1')) ?? '{}',
+ ) as Record;
+ expect(viewed['quarterly-report']).toBe('2026-08-20T12:00:00.000Z');
+ expect(window.location.pathname).toBe('/c/conversation-1');
+ expect(new URLSearchParams(window.location.search).get('artifact')).toBe(
+ 'identifier:quarterly-report',
+ );
+ expect(new URLSearchParams(window.location.search).get('artifactId')).toBe(
+ 'artifact-revision-2',
+ );
+ expect(mockSetArtifactNavigationRequest).toHaveBeenCalledWith({
+ conversationId: 'conversation-1',
+ sourceKey: 'identifier:quarterly-report',
+ originalArtifactId: 'artifact-revision-2',
+ messageId: 'message-2',
+ });
+ });
+
+ it('opens an artifact in the standalone viewer after its source conversation is detached', () => {
+ const { conversationId: _deletedConversationId, ...detachedSourceMetadata } =
+ apps[0].sourceMetadata ?? {};
+ const detachedApp = { ...apps[0], sourceMetadata: detachedSourceMetadata };
+ mockUseListArtifactAppsQuery.mockReturnValue(
+ makeListQueryResult({
+ data: {
+ pages: [{ apps: [detachedApp], has_more: false, after: null }],
+ pageParams: [undefined],
+ },
+ }),
+ );
+
+ render(
+
+
+ ,
+ );
+ fireEvent.click(screen.getByText('Quarterly Report').closest('button') as HTMLButtonElement);
+
+ expect(window.location.pathname).toBe('/apps/quarterly-report');
+ expect(mockSetArtifactNavigationRequest).not.toHaveBeenCalled();
+ });
+
+ it('exposes share for a catalog artifact whose list entry already includes SHARE', () => {
+ mockUseListArtifactAppsQuery.mockReturnValue(
+ makeListQueryResult({
+ data: {
+ pages: [
+ {
+ apps: [{ ...apps[1], permissionBits: PermissionBits.SHARE }],
+ has_more: false,
+ after: null,
+ },
+ ],
+ pageParams: [undefined],
+ },
+ }),
+ );
+
+ render(
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Share' }));
+
+ expect(screen.getByTestId('artifact-share-dialog-team-planner')).toBeInTheDocument();
+ });
+});
diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.tsx
new file mode 100644
index 00000000000..6ada7531ae7
--- /dev/null
+++ b/client/src/components/ArtifactApps/ArtifactAppsList.tsx
@@ -0,0 +1,882 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useSetAtom } from 'jotai';
+import * as Ariakit from '@ariakit/react';
+import { useNavigate } from 'react-router-dom';
+import { useTranslation } from 'react-i18next';
+import {
+ Shapes,
+ Lock,
+ Users,
+ Pin,
+ Link,
+ Share2,
+ Ellipsis,
+ PinOff,
+ LayoutGrid,
+ List,
+ Trash2,
+ FolderMinus,
+} from 'lucide-react';
+import {
+ Button,
+ DropdownPopup,
+ OGDialog,
+ OGDialogTemplate,
+ Spinner,
+ useMediaQuery,
+ useToastContext,
+} from '@librechat/client';
+import {
+ SystemRoles,
+ PermissionBits,
+ hasPermissions,
+ type TArtifactApp,
+ type ArtifactAppListScope,
+} from 'librechat-data-provider';
+import type { MenuItemProps } from '~/common';
+import {
+ useDeleteArtifactAppMutation,
+ useListArtifactAppsQuery,
+ useWithdrawArtifactVersionMutation,
+} from '~/data-provider';
+import ArtifactAppShareDialog, { useCanShareArtifactApp } from './Share';
+import ArtifactAppsAdminSettings from './ArtifactAppsAdminSettings';
+import { useAuthContext, useDebounce, useLocalize } from '~/hooks';
+import OpenSidebar from '~/components/Chat/Menus/OpenSidebar';
+import { artifactNavigationRequestAtom } from './navigation';
+import ArtifactAppsSearchBar from './ArtifactAppsSearchBar';
+import Thumbnail from './Thumbnail';
+import { cn } from '~/utils';
+
+const PINNED_ARTIFACT_APPS_KEY = 'librechat.pinnedArtifactApps';
+const VIEWED_ARTIFACT_APPS_KEY = 'librechat.viewedArtifactApps';
+const ARTIFACT_APPS_VIEW_MODE_KEY = 'librechat.artifactAppsViewMode';
+const RECENT_ACTIVITY_THRESHOLD_MS = 24 * 60 * 60 * 1000;
+const SCOPES: ArtifactAppListScope[] = ['personal', 'shared', 'all'];
+const SCOPE_LABELS = {
+ personal: 'com_ui_artifact_scope_personal',
+ shared: 'com_ui_artifact_scope_shared',
+ all: 'com_ui_artifact_scope_all',
+} as const;
+type ArtifactAppsViewMode = 'list' | 'grid';
+type ScopedArtifactActivity = {
+ scope: string | null;
+ value: T;
+};
+
+const EMPTY_PINNED_ARTIFACT_IDS = new Set();
+const EMPTY_VIEWED_ARTIFACT_TIMES = new Map();
+
+function getArtifactActivityStorageScope(userId?: string, tenantId?: string) {
+ if (!userId) {
+ return null;
+ }
+
+ return `${encodeURIComponent(tenantId ?? '__default__')}:${encodeURIComponent(userId)}`;
+}
+
+function getScopedArtifactActivityKey(baseKey: string, scope: string | null) {
+ return scope ? `${baseKey}:${scope}` : null;
+}
+
+function readArtifactAppsViewMode(): ArtifactAppsViewMode {
+ if (typeof window === 'undefined') {
+ return 'list';
+ }
+
+ try {
+ const stored = window.localStorage.getItem(ARTIFACT_APPS_VIEW_MODE_KEY);
+ return stored === 'grid' ? 'grid' : 'list';
+ } catch {
+ return 'list';
+ }
+}
+
+function writeArtifactAppsViewMode(viewMode: ArtifactAppsViewMode) {
+ try {
+ window.localStorage.setItem(ARTIFACT_APPS_VIEW_MODE_KEY, viewMode);
+ } catch {
+ // View mode is a local preference; unavailable storage should not block the catalog.
+ }
+}
+
+function readPinnedArtifactApps(scope: string | null) {
+ const storageKey = getScopedArtifactActivityKey(PINNED_ARTIFACT_APPS_KEY, scope);
+ if (typeof window === 'undefined' || !storageKey) {
+ return new Set();
+ }
+
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? '[]');
+ return new Set(Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : []);
+ } catch {
+ return new Set();
+ }
+}
+
+function writePinnedArtifactApps(scope: string | null, pinnedIds: Set) {
+ const storageKey = getScopedArtifactActivityKey(PINNED_ARTIFACT_APPS_KEY, scope);
+ if (!storageKey) {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify([...pinnedIds]));
+ } catch {
+ // Pinning is a local convenience; unavailable storage should not block the catalog.
+ }
+}
+
+function readViewedArtifactApps(scope: string | null) {
+ const storageKey = getScopedArtifactActivityKey(VIEWED_ARTIFACT_APPS_KEY, scope);
+ if (typeof window === 'undefined' || !storageKey) {
+ return new Map();
+ }
+
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? '{}');
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return new Map();
+ }
+ return new Map(
+ Object.entries(parsed).filter(
+ (entry): entry is [string, string] =>
+ typeof entry[0] === 'string' && typeof entry[1] === 'string',
+ ),
+ );
+ } catch {
+ return new Map();
+ }
+}
+
+function writeViewedArtifactApps(scope: string | null, viewedAtById: Map) {
+ const storageKey = getScopedArtifactActivityKey(VIEWED_ARTIFACT_APPS_KEY, scope);
+ if (!storageKey) {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(storageKey, JSON.stringify(Object.fromEntries(viewedAtById)));
+ } catch {
+ // Viewed timestamps are a local convenience; unavailable storage should not block opening.
+ }
+}
+
+function getArtifactAppUrl(artifactAppId: string) {
+ const path = `/apps/${artifactAppId}`;
+ return typeof window === 'undefined' ? path : new URL(path, window.location.origin).toString();
+}
+
+function formatArtifactActivityDate(
+ value: string | undefined,
+ verb: 'edited' | 'viewed',
+ localize: ReturnType,
+ locale?: string,
+) {
+ if (!value) {
+ return null;
+ }
+
+ const date = new Date(value);
+ const time = date.getTime();
+ if (Number.isNaN(time)) {
+ return null;
+ }
+
+ const now = Date.now();
+ const age = now - time;
+ const verbKey =
+ verb === 'edited' ? 'com_ui_artifact_activity_edited' : 'com_ui_artifact_activity_viewed';
+ let activity: string;
+ if (age > RECENT_ACTIVITY_THRESHOLD_MS) {
+ activity = new Intl.DateTimeFormat(locale, {
+ month: 'short',
+ day: 'numeric',
+ }).format(date);
+ } else if (age < 60_000) {
+ activity = localize('com_ui_artifact_activity_just_now');
+ } else if (age < 60 * 60_000) {
+ activity = localize('com_ui_artifact_activity_minutes_ago', {
+ 0: String(Math.floor(age / 60_000)),
+ });
+ } else {
+ activity = localize('com_ui_artifact_activity_hours_ago', {
+ 0: String(Math.floor(age / (60 * 60_000))),
+ });
+ }
+
+ return localize(verbKey, { 0: activity });
+}
+
+function ArtifactAppsViewToggle({
+ viewMode,
+ onChange,
+}: {
+ viewMode: ArtifactAppsViewMode;
+ onChange: (viewMode: ArtifactAppsViewMode) => void;
+}) {
+ const localize = useLocalize();
+ const items: Array<{ mode: ArtifactAppsViewMode; label: string; icon: typeof List }> = [
+ { mode: 'list', label: localize('com_ui_artifact_apps_list_view'), icon: List },
+ { mode: 'grid', label: localize('com_ui_artifact_apps_grid_view'), icon: LayoutGrid },
+ ];
+
+ return (
+
+ {items.map(({ mode, label, icon: Icon }) => (
+
+ ))}
+
+ );
+}
+
+function ArtifactAppMenu({
+ app,
+ isPinned,
+ onTogglePin,
+ onDeleted,
+}: {
+ app: TArtifactApp;
+ isPinned: boolean;
+ onTogglePin: (artifactAppId: string) => void;
+ onDeleted: (artifactAppId: string) => void;
+}) {
+ const localize = useLocalize();
+ const { user } = useAuthContext();
+ const { showToast } = useToastContext();
+ const canShare = useCanShareArtifactApp(app);
+ const canDelete =
+ app.createdBy === user?.id ||
+ user?.role === SystemRoles.ADMIN ||
+ hasPermissions(app.permissionBits ?? 0, PermissionBits.DELETE);
+ const canManageVersion =
+ app.createdBy === user?.id ||
+ user?.role === SystemRoles.ADMIN ||
+ hasPermissions(app.permissionBits ?? 0, PermissionBits.EDIT);
+ const canWithdraw = canManageVersion && Boolean(app.activeVersionId);
+ const deleteArtifact = useDeleteArtifactAppMutation();
+ const withdrawVersion = useWithdrawArtifactVersionMutation();
+ const [menuOpen, setMenuOpen] = useState(false);
+ const [shareOpen, setShareOpen] = useState(false);
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [withdrawOpen, setWithdrawOpen] = useState(false);
+
+ const copyLink = async () => {
+ try {
+ await navigator.clipboard.writeText(getArtifactAppUrl(app.artifactAppId));
+ showToast({ status: 'success', message: localize('com_ui_artifact_link_copied') });
+ } catch {
+ showToast({ status: 'error', message: localize('com_ui_copy_failed') });
+ }
+ };
+
+ const items: MenuItemProps[] = [
+ {
+ id: `artifact-pin-${app.artifactAppId}`,
+ label: localize(isPinned ? 'com_ui_unpin' : 'com_ui_pin'),
+ icon: isPinned ? : ,
+ ariaChecked: isPinned,
+ onClick: () => onTogglePin(app.artifactAppId),
+ },
+ {
+ id: `artifact-copy-link-${app.artifactAppId}`,
+ label: localize('com_ui_copy_link'),
+ icon: ,
+ onClick: () => void copyLink(),
+ },
+ ];
+
+ if (canShare) {
+ items.push({
+ id: `artifact-share-${app.artifactAppId}`,
+ label: localize('com_ui_share'),
+ icon: ,
+ onClick: () => setShareOpen(true),
+ });
+ }
+
+ if (canWithdraw) {
+ items.push({
+ id: `artifact-withdraw-${app.artifactAppId}`,
+ label: localize('com_ui_withdraw'),
+ icon: ,
+ onClick: () => setWithdrawOpen(true),
+ });
+ }
+
+ if (canDelete) {
+ items.push({ id: `artifact-delete-separator-${app.artifactAppId}`, separate: true });
+ items.push({
+ id: `artifact-delete-${app.artifactAppId}`,
+ label: localize('com_ui_delete'),
+ icon: ,
+ className:
+ 'text-text-destructive hover:bg-destructive/10 hover:text-text-destructive focus:bg-destructive/10 focus:text-text-destructive',
+ onClick: () => setDeleteOpen(true),
+ });
+ }
+
+ const confirmDelete = () => {
+ if (deleteArtifact.isLoading) {
+ return;
+ }
+ deleteArtifact.mutate(app.artifactAppId, {
+ onSuccess: () => {
+ onDeleted(app.artifactAppId);
+ setDeleteOpen(false);
+ showToast({ status: 'success', message: localize('com_ui_artifact_delete_success') });
+ },
+ onError: () => {
+ showToast({ status: 'error', message: localize('com_ui_artifact_delete_error') });
+ },
+ });
+ };
+
+ const confirmWithdraw = () => {
+ if (withdrawVersion.isLoading || !app.activeVersionId) {
+ return;
+ }
+ withdrawVersion.mutate(
+ { artifactAppId: app.artifactAppId, versionId: app.activeVersionId },
+ {
+ onSuccess: () => {
+ setWithdrawOpen(false);
+ showToast({ status: 'success', message: localize('com_ui_artifact_withdraw_success') });
+ },
+ onError: () => {
+ showToast({ status: 'error', message: localize('com_ui_artifact_withdraw_error') });
+ },
+ },
+ );
+ };
+
+ return (
+ <>
+ {shareOpen && (
+ {
+ if (!open) {
+ setShareOpen(false);
+ }
+ }}
+ />
+ )}
+
+
+ {localize('com_ui_artifact_delete_confirm', { 0: app.title })}
+
+ }
+ selection={
+
+ }
+ />
+
+
+
+ {localize('com_ui_artifact_withdraw_confirm', { 0: app.title })}
+
+ }
+ selection={
+
+ }
+ />
+
+
+
+
+ }
+ items={items}
+ />
+ >
+ );
+}
+
+export default function ArtifactAppsList() {
+ const localize = useLocalize();
+ const { i18n } = useTranslation();
+ const navigate = useNavigate();
+ const { user } = useAuthContext();
+ const { showToast } = useToastContext();
+ const setArtifactNavigationRequest = useSetAtom(artifactNavigationRequestAtom);
+ const isSmallScreen = useMediaQuery('(max-width: 768px)');
+ const [searchQuery, setSearchQuery] = useState('');
+ const [scope, setScope] = useState('personal');
+ const activityStorageScope = getArtifactActivityStorageScope(user?.id, user?.tenantId);
+ const [pinnedArtifactState, setPinnedArtifactState] = useState<
+ ScopedArtifactActivity>
+ >(() => ({
+ scope: activityStorageScope,
+ value: readPinnedArtifactApps(activityStorageScope),
+ }));
+ const [viewedArtifactState, setViewedArtifactState] = useState<
+ ScopedArtifactActivity