beam: persist ready viewer attachments

This commit is contained in:
DCCONSTRUCTIONS 2026-06-05 01:36:34 +03:00
parent 677916389f
commit 31dca29560
5 changed files with 111 additions and 6 deletions

View File

@ -257,6 +257,25 @@ class IssueAttachmentV2Endpoint(BaseAPIView):
@allow_permission([ROLE.ADMIN, ROLE.MEMBER])
def patch(self, request, slug, project_id, issue_id, pk):
issue_attachment = FileAsset.objects.get(pk=pk, workspace__slug=slug, project_id=project_id)
beam_viewer = request.data.get("beamViewer")
if isinstance(beam_viewer, dict):
attributes = issue_attachment.attributes or {}
existing_beam_viewer = attributes.get("beamViewer")
if not isinstance(existing_beam_viewer, dict):
return Response(
{"error": "The attachment is not a Beam Viewer reference.", "status": False},
status=status.HTTP_400_BAD_REQUEST,
)
attributes["beamViewer"] = {
**existing_beam_viewer,
**beam_viewer,
}
issue_attachment.attributes = attributes
issue_attachment.is_uploaded = True
issue_attachment.save(update_fields=["attributes", "is_uploaded", "updated_at"])
return Response(IssueAttachmentSerializer(issue_attachment).data, status=status.HTTP_200_OK)
serializer = IssueAttachmentSerializer(issue_attachment)
if not attachment_object_exists(issue_attachment):
return Response(

View File

@ -189,7 +189,10 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
key={attachmentId}
attachmentId={attachmentId}
disabled={disabled}
issueId={issueId}
issueServiceType={issueServiceType}
projectId={projectId}
workspaceSlug={workspaceSlug}
/>
))}
</div>

View File

@ -5,7 +5,7 @@
*/
import { observer } from "mobx-react";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
@ -27,6 +27,7 @@ import {
fetchBeamConversionStatus,
getBeamViewerAttachment,
isBeamModelFile,
type TBeamViewerAttachment,
type TBeamConversionStatus,
} from "@/helpers/beam-viewer";
import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attachment-pdf-preview";
@ -35,11 +36,15 @@ import { IssueAttachmentPdfPreview, IssueAttachmentPdfThumbnail } from "./attach
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { useMember } from "@/hooks/store/use-member";
import { usePlatformOS } from "@/hooks/use-platform-os";
import { IssueAttachmentService } from "@/services/issue";
type TIssueAttachmentsListItem = {
attachmentId: string;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
issueId: string;
projectId: string;
workspaceSlug: string;
};
const IMAGE_EXTENSIONS = new Set(["apng", "avif", "bmp", "gif", "jpg", "jpeg", "png", "svg", "webp"]);
@ -53,6 +58,18 @@ const appendSearchParam = (url: string | undefined, key: string, value: string):
return `${urlWithoutHash}${separator}${key}=${encodeURIComponent(value)}${hash ? `#${hash}` : ""}`;
};
const getUsableBeamViewerUrl = (url: string | undefined): string | undefined => {
if (!url) return undefined;
try {
const parsedUrl = new URL(url, "http://localhost");
return parsedUrl.searchParams.has("url") || parsedUrl.searchParams.has("src") || parsedUrl.searchParams.has("projectId")
? url
: undefined;
} catch (_error) {
return undefined;
}
};
const getPreviewType = (extension: string) => {
const normalizedExtension = extension.toLowerCase();
if (IMAGE_EXTENSIONS.has(normalizedExtension)) return "image";
@ -69,14 +86,49 @@ const getBeamConversionStatusLabel = (status: string | undefined): string | null
return "Ожидает дерево";
};
const buildSyncedBeamViewerAttachment = (
beamViewer: TBeamViewerAttachment,
status: TBeamConversionStatus,
fullFileName: string
): TBeamViewerAttachment | null => {
if (status.status !== "ready" || !status.artifactUrl) return null;
const artifactType = status.artifactType || status.targetFormat || "gltf";
const targetFormat: "glb" | "xkt" = status.targetFormat || (artifactType === "xkt" ? "xkt" : "glb");
return {
...beamViewer,
previewAvailable: true,
viewerUrl: buildBeamViewerUrl({
name: fullFileName,
src: status.artifactUrl,
type: artifactType,
}),
conversion: {
...(beamViewer.conversion ?? {}),
artifactSrc: status.artifactSrc,
artifactType,
componentTreeRequired: status.componentTreeRequired ?? beamViewer.conversion?.componentTreeRequired ?? true,
message: status.message || beamViewer.conversion?.message,
metadataSrc: status.metadataSrc,
sourceFormat: status.sourceFormat || beamViewer.conversion?.sourceFormat || beamViewer.type,
sourceSrc: status.sourceSrc || beamViewer.conversion?.sourceSrc,
status: "ready",
targetFormat,
updatedAt: status.updatedAt || beamViewer.conversion?.updatedAt,
},
};
};
export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation();
// props
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const { attachmentId, disabled, issueId, issueServiceType = EIssueServiceType.ISSUES, projectId, workspaceSlug } = props;
// store hooks
const { getUserDetails } = useMember();
const {
attachment: { getAttachmentById },
fetchAttachments,
toggleDeleteAttachmentModal,
} = useIssueDetail(issueServiceType);
// derived values
@ -91,16 +143,17 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const beamViewer = getBeamViewerAttachment(attachment);
const [beamConversionStatus, setBeamConversionStatus] = useState<TBeamConversionStatus | null>(null);
const [beamConversionError, setBeamConversionError] = useState<string | null>(null);
const attachmentService = useMemo(() => new IssueAttachmentService(issueServiceType), [issueServiceType]);
const resolvedArtifactUrl = beamConversionStatus?.status === "ready" ? beamConversionStatus.artifactUrl : undefined;
const storedModelViewerUrl = getUsableBeamViewerUrl(beamViewer?.viewerUrl);
const modelViewerUrl =
beamViewer?.viewerUrl ||
(resolvedArtifactUrl
? buildBeamViewerUrl({
name: fullFileName,
src: resolvedArtifactUrl,
type: beamConversionStatus?.artifactType || "gltf",
})
: undefined);
: undefined) || storedModelViewerUrl;
const modelDownloadUrl = beamViewer?.downloadUrl || beamViewer?.src;
const previewDownloadUrl = modelDownloadUrl || fileURL;
const isBeamModel = isBeamModelFile(fullFileName);
@ -160,7 +213,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const { isMobile } = usePlatformOS();
useEffect(() => {
if (!beamViewer || beamViewer.viewerUrl || !beamViewer.src) return;
if (!beamViewer || storedModelViewerUrl || !beamViewer.src) return;
let isMounted = true;
let timeoutId: number | undefined;
@ -171,6 +224,13 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
if (!isMounted) return;
setBeamConversionError(null);
setBeamConversionStatus(status);
const syncedBeamViewer = buildSyncedBeamViewerAttachment(beamViewer, status, fullFileName);
if (syncedBeamViewer && syncedBeamViewer.viewerUrl !== beamViewer.viewerUrl) {
attachmentService
.updateBeamIssueAttachmentReference(workspaceSlug, projectId, issueId, attachmentId, syncedBeamViewer)
.then(() => fetchAttachments(workspaceSlug, projectId, issueId))
.catch((error) => console.error("Error in syncing Beam attachment metadata:", error));
}
if (status.status !== "ready" && status.status !== "failed") {
timeoutId = window.setTimeout(pollStatus, 5000);
}
@ -187,7 +247,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
isMounted = false;
if (timeoutId) window.clearTimeout(timeoutId);
};
}, [beamViewer]);
}, [attachmentId, attachmentService, beamViewer, fetchAttachments, fullFileName, issueId, projectId, storedModelViewerUrl, workspaceSlug]);
if (!attachment) return <></>;
@ -430,6 +490,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
</div>
{modelViewerUrl && (
<iframe
key={modelViewerUrl}
src={modelViewerUrl}
title={`Beam Viewer: ${fullFileName}`}
tabIndex={-1}

View File

@ -76,6 +76,23 @@ export class IssueAttachmentService extends APIService {
});
}
async updateBeamIssueAttachmentReference(
workspaceSlug: string,
projectId: string,
issueId: string,
attachmentId: string,
beamViewer: TBeamViewerAttachment
): Promise<TIssueAttachment> {
return this.patch(
`/api/assets/v2/workspaces/${workspaceSlug}/projects/${projectId}/${this.serviceType}/${issueId}/attachments/${attachmentId}/`,
{ beamViewer }
)
.then((response) => response?.data)
.catch((error) => {
throw error?.response?.data ?? error;
});
}
async uploadIssueAttachment(
workspaceSlug: string,
projectId: string,

View File

@ -4,11 +4,16 @@ import type { TIssueAttachment } from "@plane/types";
export type TBeamViewerAttachment = {
backend: "beam-viewer-local" | "beam-viewer";
conversion?: {
artifactSrc?: string;
artifactType?: string;
componentTreeRequired: boolean;
message?: string;
metadataSrc?: string;
sourceFormat: string;
sourceSrc?: string;
status: "conversion_required" | "processing" | "ready" | "failed";
targetFormat: "glb" | "xkt";
updatedAt?: string;
};
downloadUrl: string;
originalFilename: string;