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
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
};
2 changes: 2 additions & 0 deletions api/server/middleware/accessResources/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -14,4 +15,5 @@ module.exports = {
canAccessPromptGroupResource,
canAccessMCPServerResource,
canAccessSkillResource,
canAccessArtifactAppResource,
};
102 changes: 102 additions & 0 deletions api/server/routes/artifactApps.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
const express = require('express');
const { createArtifactAppHandlers } = require('@librechat/api');
const { PermissionBits } = require('librechat-data-provider');
const {
createArtifactAppWithVersion,
getArtifactAppByAppId,
resolveArtifactAppId,
listArtifactApps,
updateArtifactApp,
deleteArtifactApp,
getArtifactVersion,
listArtifactVersions,
createArtifactVersion,
releaseArtifactVersion,
activateArtifactVersion,
withdrawArtifactVersion,
recordAuditEntry,
} = require('~/models');
const { requireJwtAuth, canAccessArtifactAppResource } = require('~/server/middleware');
const {
findAccessibleResources,
grantPermission,
} = require('~/server/services/PermissionService');
const configMiddleware = require('~/server/middleware/config/app');

const router = express.Router();

router.use(requireJwtAuth);
router.use(configMiddleware);

const handlers = createArtifactAppHandlers({
createArtifactAppWithVersion,
getArtifactAppByAppId,
resolveArtifactAppId,
listArtifactApps,
updateArtifactApp,
deleteArtifactApp,
getArtifactVersion,
listArtifactVersions,
createArtifactVersion,
releaseArtifactVersion,
activateArtifactVersion,
withdrawArtifactVersion,
findAccessibleResources,
grantPermission,
recordAuditEntry,
});

// Collection
router.get('/', handlers.list);
router.post('/', handlers.publish);

// Single app
router.get(
'/:id',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
handlers.get,
);
router.patch(
'/:id',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.update,
);
router.delete(
'/:id',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.DELETE }),
handlers.remove,
);

// Versions
router.get(
'/:id/versions',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
handlers.listVersions,
);
router.post(
'/:id/versions',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.createVersion,
);
router.get(
'/:id/versions/:versionId',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.VIEW }),
handlers.getVersion,
);
router.post(
'/:id/versions/:versionId/release',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.releaseVersion,
);
router.post(
'/:id/versions/:versionId/activate',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.activateVersion,
);
router.post(
'/:id/versions/:versionId/withdraw',
canAccessArtifactAppResource({ requiredPermission: PermissionBits.EDIT }),
handlers.withdrawVersion,
);

module.exports = router;
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const presets = require('./presets');
const projects = require('./projects');
const prompts = require('./prompts');
const skills = require('./skills');
const artifactApps = require('./artifactApps');
const balance = require('./balance');
const actions = require('./actions');
const apiKeys = require('./apiKeys');
Expand Down Expand Up @@ -66,6 +67,7 @@ module.exports = {
prompts,
projects,
skills,
artifactApps,
actions,
presets,
balance,
Expand Down
41 changes: 41 additions & 0 deletions client/src/components/ArtifactApps/AppRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
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';

export default function AppRenderer({
title,
version,
}: {
title: string;
version: TArtifactVersion;
}) {
const previewRef = useRef<SandpackPreviewRef>(null as unknown as SandpackPreviewRef);
const { data: startupConfig } = useGetStartupConfig();

const artifact: Artifact = {
id: version.artifactVersionId,
lastUpdateTime: Date.now(),
type: version.artifactType,
content: version.sourceSnapshot,
title,
};

const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });

return (
<div className="h-full w-full overflow-hidden" role="region" aria-label={title}>
<ArtifactPreview
files={files}
fileKey={fileKey}
template={template}
previewRef={previewRef}
sharedProps={sharedProps}
startupConfig={startupConfig}
/>
</div>
);
}
105 changes: 105 additions & 0 deletions client/src/components/ArtifactApps/ArtifactAppsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { useNavigate } from 'react-router-dom';
import { Rocket, Lock, Globe, Users } from 'lucide-react';
import { useListArtifactAppsQuery } from '~/data-provider';
import { useLocalize } from '~/hooks';
import type { TArtifactApp } from 'librechat-data-provider';

function visibilityIcon(v: TArtifactApp['visibility']) {
if (v === 'public') return <Globe size={14} className="text-text-secondary" aria-hidden="true" />;
if (v === 'tenant') return <Users size={14} className="text-text-secondary" aria-hidden="true" />;
return <Lock size={14} className="text-text-secondary" aria-hidden="true" />;
}

function statusBadge(s: TArtifactApp['status']) {
const colors: Record<TArtifactApp['status'], string> = {
draft: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300',
pending_review: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300',
published: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300',
suspended: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300',
archived: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
return (
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${colors[s] ?? ''}`}>
{s.replace('_', ' ')}
</span>
);
}

export default function ArtifactAppsList() {
const localize = useLocalize();
const navigate = useNavigate();
const { data, isLoading, isError } = useListArtifactAppsQuery();
const apps = data?.apps ?? [];

if (isLoading) {
return (
<div className="flex h-full items-center justify-center p-8 text-text-secondary">
{localize('com_ui_artifact_app_loading')}
</div>
);
}

if (isError) {
return (
<div className="flex h-full items-center justify-center p-8 text-text-secondary">
{localize('com_ui_artifact_app_not_found')}
</div>
);
}

return (
<div className="flex h-full flex-col bg-surface-primary">
<header className="border-b border-border-light px-6 py-4">
<div className="flex items-center gap-2">
<Rocket size={20} className="text-text-primary" aria-hidden="true" />
<h1 className="text-lg font-semibold text-text-primary">
{localize('com_ui_artifact_apps')}
</h1>
</div>
<p className="mt-1 text-sm text-text-secondary">
{localize('com_ui_artifact_apps_description')}
</p>
</header>

{apps.length === 0 ? (
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-8 text-center">
<Rocket size={40} className="text-text-secondary opacity-40" aria-hidden="true" />
<p className="text-text-secondary">{localize('com_ui_artifact_apps_empty')}</p>
<p className="max-w-sm text-sm text-text-secondary">
{localize('com_ui_artifact_apps_empty_hint')}
</p>
</div>
) : (
<ul className="flex-1 overflow-y-auto p-4">
{apps.map((app) => (
<li key={app.artifactAppId}>
<button
className="mb-3 flex w-full items-start gap-4 rounded-xl border border-border-light bg-surface-secondary p-4 text-left transition-colors hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => navigate(`/apps/${app.artifactAppId}`)}
>
<div className="flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg bg-surface-primary text-xl">
{app.icon ?? <Rocket size={20} className="text-text-secondary" />}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate font-medium text-text-primary">{app.title}</span>
{visibilityIcon(app.visibility)}
{statusBadge(app.status)}
</div>
{app.description && (
<p className="mt-0.5 truncate text-sm text-text-secondary">
{app.description}
</p>
)}
<p className="mt-1 text-xs text-text-secondary">
v{app.latestVersionNumber}
</p>
</div>
</button>
</li>
))}
</ul>
)}
</div>
);
}
Loading