diff --git a/api/server/index.js b/api/server/index.js
index a27cedbb501..3cbac5df624 100644
--- a/api/server/index.js
+++ b/api/server/index.js
@@ -286,6 +286,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..b2fb9c71139
--- /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/artifactApps.js b/api/server/routes/artifactApps.js
new file mode 100644
index 00000000000..1ce190e9e02
--- /dev/null
+++ b/api/server/routes/artifactApps.js
@@ -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;
diff --git a/api/server/routes/index.js b/api/server/routes/index.js
index c2578e43532..ff8f53894a7 100644
--- a/api/server/routes/index.js
+++ b/api/server/routes/index.js
@@ -18,6 +18,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');
@@ -68,6 +69,7 @@ module.exports = {
prompts,
projects,
skills,
+ artifactApps,
actions,
presets,
balance,
diff --git a/client/src/components/ArtifactApps/AppRenderer.tsx b/client/src/components/ArtifactApps/AppRenderer.tsx
new file mode 100644
index 00000000000..a64cb204020
--- /dev/null
+++ b/client/src/components/ArtifactApps/AppRenderer.tsx
@@ -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(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 (
+
+ );
+}
diff --git a/client/src/components/ArtifactApps/ArtifactAppsList.tsx b/client/src/components/ArtifactApps/ArtifactAppsList.tsx
new file mode 100644
index 00000000000..1df227beaf1
--- /dev/null
+++ b/client/src/components/ArtifactApps/ArtifactAppsList.tsx
@@ -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 ;
+ if (v === 'tenant') return ;
+ return ;
+}
+
+function statusBadge(s: TArtifactApp['status']) {
+ const colors: Record = {
+ 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 (
+
+ {s.replace('_', ' ')}
+
+ );
+}
+
+export default function ArtifactAppsList() {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const { data, isLoading, isError } = useListArtifactAppsQuery();
+ const apps = data?.apps ?? [];
+
+ if (isLoading) {
+ return (
+
+ {localize('com_ui_artifact_app_loading')}
+
+ );
+ }
+
+ if (isError) {
+ return (
+
+ {localize('com_ui_artifact_app_not_found')}
+
+ );
+ }
+
+ return (
+
+
+
+ {apps.length === 0 ? (
+
+
+
{localize('com_ui_artifact_apps_empty')}
+
+ {localize('com_ui_artifact_apps_empty_hint')}
+
+
+ ) : (
+
+ {apps.map((app) => (
+ -
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/client/src/components/ArtifactApps/PublishAppDialog.tsx b/client/src/components/ArtifactApps/PublishAppDialog.tsx
new file mode 100644
index 00000000000..8fec57db51a
--- /dev/null
+++ b/client/src/components/ArtifactApps/PublishAppDialog.tsx
@@ -0,0 +1,320 @@
+import { useState } from 'react';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { X, Rocket } from 'lucide-react';
+import * as Dialog from '@radix-ui/react-dialog';
+import { Button } from '@librechat/client';
+import { usePublishArtifactAppMutation } from '~/data-provider';
+import { useLocalize } from '~/hooks';
+import { useNavigate } from 'react-router-dom';
+import type { Artifact } from '~/common';
+
+const formSchema = z.object({
+ title: z.string().min(1, 'Title is required').max(200),
+ description: z.string().max(2000).optional(),
+ visibility: z.enum(['private', 'restricted', 'tenant', 'public']).default('private'),
+ allowEmbed: z.boolean().default(false),
+ allowFork: z.boolean().default(false),
+ allowAnonymousView: z.boolean().default(false),
+ changelog: z.string().optional(),
+});
+type FormValues = z.infer;
+
+interface PublishAppDialogProps {
+ open: boolean;
+ onOpenChange: (v: boolean) => void;
+ artifact: Artifact;
+ conversationId?: string;
+ messageId?: string;
+}
+
+type RuntimeType = 'react' | 'html' | 'mermaid';
+
+function getRuntimeTypeFromArtifact(artifact: Artifact): RuntimeType {
+ const t = artifact.type;
+
+ if (t === 'application/vnd.mermaid') return 'mermaid';
+ if (t === 'text/html' || t === 'application/vnd.code-html') return 'html';
+
+ // alles andere als React-App behandeln
+ return 'react';
+}
+
+const STEPS = ['general', 'security', 'publish'] as const;
+type Step = (typeof STEPS)[number];
+
+export default function PublishAppDialog({
+ open,
+ onOpenChange,
+ artifact,
+ conversationId,
+ messageId,
+}: PublishAppDialogProps) {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const [step, setStep] = useState('general');
+ const publishMutation = usePublishArtifactAppMutation();
+
+ const {
+ register,
+ handleSubmit,
+ watch,
+ formState: { errors },
+ } = useForm({
+ defaultValues: {
+ title: artifact.title ?? '',
+ description: '',
+ visibility: 'private',
+ allowEmbed: false,
+ allowFork: false,
+ allowAnonymousView: false,
+ changelog: '',
+ },
+ });
+
+ const runtimeType = getRuntimeTypeFromArtifact(artifact);
+ const visibility = watch('visibility');
+
+ const onSubmit = handleSubmit(async (data) => {
+ if (step !== 'publish') {
+ setStep(STEPS[STEPS.indexOf(step) + 1]);
+ return;
+ }
+ try {
+ const result = await publishMutation.mutateAsync({
+ title: data.title,
+ description: data.description,
+ visibility: data.visibility,
+ allowEmbed: data.allowEmbed,
+ allowFork: data.allowFork,
+ allowAnonymousView: data.allowAnonymousView,
+ changelog: data.changelog,
+ artifact: {
+ type: runtimeType,
+ content: artifact.content ?? '',
+ title: artifact.title,
+ },
+ source: conversationId
+ ? {
+ conversationId,
+ messageId,
+ originalArtifactId: artifact.id,
+ }
+ : undefined,
+ });
+ onOpenChange(false);
+ navigate(`/apps/${result.app.artifactAppId}`);
+ } catch {
+ // Fehler wird über publishMutation.isError angezeigt
+ }
+ });
+
+ const stepLabel: Record = {
+ general: localize('com_ui_artifact_app_step_general'),
+ security: localize('com_ui_artifact_app_step_security'),
+ publish: localize('com_ui_artifact_app_step_publish'),
+ };
+
+ const stepIndex = STEPS.indexOf(step);
+ const isLastStep = step === 'publish';
+
+ return (
+
+
+
+
+
+
+
+
+ {localize('com_ui_artifact_app_publish')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/client/src/components/ArtifactApps/PublishArtifactButton.tsx b/client/src/components/ArtifactApps/PublishArtifactButton.tsx
new file mode 100644
index 00000000000..94d0fdca76b
--- /dev/null
+++ b/client/src/components/ArtifactApps/PublishArtifactButton.tsx
@@ -0,0 +1,49 @@
+import { useState } from 'react';
+import { Rocket } from 'lucide-react';
+import { Button } from '@librechat/client';
+import { useLocalize } from '~/hooks';
+import PublishAppDialog from './PublishAppDialog';
+import type { Artifact } from '~/common';
+
+interface Props {
+ artifact: Artifact;
+ conversationId?: string;
+ messageId?: string;
+}
+
+export default function PublishArtifactButton({ artifact, conversationId, messageId }: Props) {
+ const localize = useLocalize();
+ const [open, setOpen] = useState(false);
+
+ const publishableTypes = [
+ 'application/vnd.react',
+ 'application/vnd.ant.react',
+ 'text/html',
+ 'application/vnd.code-html',
+ ];
+ if (!artifact.type || !publishableTypes.includes(artifact.type)) {
+ return null;
+ }
+
+ return (
+ <>
+
+
+ >
+ );
+}
diff --git a/client/src/components/ArtifactApps/StandaloneAppView.tsx b/client/src/components/ArtifactApps/StandaloneAppView.tsx
new file mode 100644
index 00000000000..e726099341b
--- /dev/null
+++ b/client/src/components/ArtifactApps/StandaloneAppView.tsx
@@ -0,0 +1,107 @@
+import { useParams, useNavigate } from 'react-router-dom';
+import type { TArtifactVersion } from 'librechat-data-provider';
+import {
+ useGetArtifactAppQuery,
+ useGetArtifactAppVersionQuery,
+ useListArtifactAppVersionsQuery,
+} from '~/data-provider';
+import useLocalize from '~/hooks/useLocalize';
+import AppRenderer from './AppRenderer';
+
+function errorStatus(error: unknown): number | undefined {
+ return (error as { response?: { status?: number } } | null)?.response?.status;
+}
+
+function StateMessage({ message }: { message: string }) {
+ return (
+
+ );
+}
+
+export default function StandaloneAppView() {
+ const localize = useLocalize();
+ const navigate = useNavigate();
+ const { artifactAppId, versionId } = useParams<{
+ artifactAppId: string;
+ versionId?: string;
+ }>();
+
+ const appQuery = useGetArtifactAppQuery(artifactAppId);
+ const versionQuery = useGetArtifactAppVersionQuery(artifactAppId, versionId, {
+ enabled: !!versionId,
+ });
+ const versionsQuery = useListArtifactAppVersionsQuery(artifactAppId);
+
+ if (appQuery.isLoading) {
+ return ;
+ }
+
+ if (appQuery.isError) {
+ const status = errorStatus(appQuery.error);
+ if (status === 403) {
+ return ;
+ }
+ return ;
+ }
+
+ const app = appQuery.data?.app;
+ if (!app) {
+ return ;
+ }
+
+ if (app.status === 'suspended') {
+ return ;
+ }
+ if (app.status === 'archived') {
+ return ;
+ }
+
+ const version: TArtifactVersion | null | undefined = versionId
+ ? versionQuery.data
+ : appQuery.data?.version;
+
+ const versions = versionsQuery.data?.versions ?? [];
+
+ return (
+
+
+
+ {version ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
diff --git a/client/src/components/ArtifactApps/index.ts b/client/src/components/ArtifactApps/index.ts
new file mode 100644
index 00000000000..e3bdcfb5b2f
--- /dev/null
+++ b/client/src/components/ArtifactApps/index.ts
@@ -0,0 +1,5 @@
+export { default as StandaloneAppView } from './StandaloneAppView';
+export { default as AppRenderer } from './AppRenderer';
+export { default as PublishAppDialog } from './PublishAppDialog';
+export { default as PublishArtifactButton } from './PublishArtifactButton';
+export { default as ArtifactAppsList } from './ArtifactAppsList';
diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx
index 40e82eb0822..19eba762b00 100644
--- a/client/src/components/Artifacts/Artifacts.tsx
+++ b/client/src/components/Artifacts/Artifacts.tsx
@@ -9,6 +9,7 @@ import CopyButton from '~/components/Messages/Content/CopyButton';
import { useShareContext, useMutationState } from '~/Providers';
import useArtifacts from '~/hooks/Artifacts/useArtifacts';
import DownloadArtifact from './DownloadArtifact';
+import { PublishArtifactButton } from '~/components/ArtifactApps';
import ArtifactVersion from './ArtifactVersion';
import ArtifactTabs from './ArtifactTabs';
import { isCodeOnlyArtifact, isPreviewOnlyArtifact } from '~/utils/artifacts';
@@ -330,6 +331,7 @@ export default function Artifacts() {
)}
+