feat(canvas): Agent引用与局部重绘 - 完善附件预览多图展开并修复取消清理
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,9 +2,11 @@ import { nanoid } from "nanoid";
|
||||
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { canGenerateImageInPlace, findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
|
||||
import { cancelIncompleteImageBatch, retireImageBatchChildren } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { buildImageGenerationNodeTitle } from "@/lib/canvas/canvas-generation-title";
|
||||
import { nodeSizeFromRatio } from "@/lib/canvas/canvas-node-size";
|
||||
import { canvasImageReferenceLimitError, buildImageGenerationMetadata, getGenerationCount, isGenerationCanceled, runCanvasGenerationTaskToConsumer } from "@/lib/canvas/canvas-project-generation";
|
||||
import { imageGenerationReferenceConnections } from "@/lib/canvas/canvas-resource-references";
|
||||
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationFailureMetadata, type GenerationFailureMetadata } from "@/lib/generation-error";
|
||||
import { CanvasNodeType, type CanvasNodeData } from "@/types/canvas";
|
||||
@@ -21,6 +23,7 @@ export async function executeImageGeneration({
|
||||
nodeId,
|
||||
sourceNode,
|
||||
canvasNodes,
|
||||
canvasConnections,
|
||||
prompt,
|
||||
effectivePrompt,
|
||||
generationConfig,
|
||||
@@ -52,6 +55,9 @@ export async function executeImageGeneration({
|
||||
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
|
||||
const isImageNode = sourceNode?.type === CanvasNodeType.Image;
|
||||
const reuseSourceNode = canGenerateImageInPlace(sourceNode);
|
||||
const retired = reuseSourceNode && sourceNode ? retireImageBatchChildren(sourceNode, canvasNodes, canvasConnections) : { nodes: canvasNodes, connections: canvasConnections, removedIds: [] as string[] };
|
||||
const workingNodes = retired.nodes;
|
||||
const workingConnections = retired.connections;
|
||||
// 已有图片生成新结果并保留旧版本;参考图只来自入边,避免把旧结果误当成自身输入。
|
||||
const referenceImages = generationContext.referenceImages;
|
||||
const generationType = referenceImages.length ? ("edit" as const) : ("generation" as const);
|
||||
@@ -78,7 +84,7 @@ export async function executeImageGeneration({
|
||||
x: parentPosition.x + parentWidth + 96,
|
||||
y: parentPosition.y + parentHeight / 2 - rootHeight / 2,
|
||||
};
|
||||
const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(canvasNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, outputNodeSize, childIds.length));
|
||||
const rootPosition = reuseSourceNode ? parentPosition : findAvailableGenerationGroupPosition(workingNodes, preferredPosition, imageGenerationGroupSize({ width: rootWidth, height: rootHeight }, outputNodeSize, childIds.length));
|
||||
|
||||
const rootNode: CanvasNodeData = {
|
||||
id: rootId,
|
||||
@@ -101,7 +107,7 @@ export async function executeImageGeneration({
|
||||
...generationMetadata,
|
||||
...styleMetadata,
|
||||
...skillMetadata,
|
||||
imageBatchExpanded: count > 1 ? false : undefined,
|
||||
imageBatchExpanded: count > 1 ? true : undefined,
|
||||
generationErrorCode: undefined,
|
||||
resourceReloadAvailable: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
@@ -127,10 +133,14 @@ export async function executeImageGeneration({
|
||||
failedPromptFingerprint: undefined,
|
||||
},
|
||||
}));
|
||||
const batchConnections = [...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]), ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))];
|
||||
const batchConnections = [
|
||||
...(reuseSourceNode ? [] : imageGenerationReferenceConnections(nodeId, rootId, workingNodes, workingConnections, nanoid)),
|
||||
...(reuseSourceNode ? [] : [{ id: nanoid(), fromNodeId: nodeId, toNodeId: rootId }]),
|
||||
...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId })),
|
||||
];
|
||||
|
||||
const nextNodes: CanvasNodeData[] = [
|
||||
...canvasNodes.map((node) => {
|
||||
...workingNodes.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
if (isConfigNode) return { ...node, metadata: { ...node.metadata, ...canvasGenerationPromptMetadata(prompt, effectivePrompt), status: NODE_STATUS_LOADING, errorDetails: undefined } };
|
||||
if (reuseSourceNode) return { ...node, position: rootNode.position, width: rootNode.width, height: rootNode.height, title: rootNode.title, metadata: { ...node.metadata, ...rootNode.metadata, errorDetails: undefined } };
|
||||
@@ -150,7 +160,8 @@ export async function executeImageGeneration({
|
||||
|
||||
setNodes(nextNodes);
|
||||
setConnections((current) => {
|
||||
const nextConnections = [...current, ...batchConnections];
|
||||
const removed = new Set(retired.removedIds);
|
||||
const nextConnections = [...current.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)), ...batchConnections];
|
||||
if (projectId) {
|
||||
useCanvasStore.getState().updateProject(projectId, { nodes: nextNodes, connections: nextConnections });
|
||||
}
|
||||
@@ -253,7 +264,14 @@ export async function executeImageGeneration({
|
||||
);
|
||||
if (count > 1) finishGenerationRequest(rootId, controller);
|
||||
if (controller.signal.aborted) {
|
||||
setNodes((current) => current.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node)));
|
||||
setNodes((current) => {
|
||||
const cancelled = cancelIncompleteImageBatch(rootId, childIds, current, []);
|
||||
if (cancelled.removedIds.length) {
|
||||
const removed = new Set(cancelled.removedIds);
|
||||
setConnections((connections) => connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)));
|
||||
}
|
||||
return cancelled.nodes.map((node) => (node.id === nodeId && isConfigNode && node.metadata?.status === NODE_STATUS_LOADING ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } } : node));
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (hasFailure) showError(hasSuccess ? "部分图片生成失败" : "全部图片生成失败");
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CanvasNodeMaskEditDialog, type CanvasImageMaskEditPayload } from "@/com
|
||||
import { CanvasNodeSplitDialog, type CanvasImageSplitParams } from "@/components/canvas/canvas-node-split-dialog";
|
||||
import { CanvasNodeUpscaleDialog, type CanvasImageUpscaleParams } from "@/components/canvas/canvas-node-upscale-dialog";
|
||||
import type { CanvasNodeData } from "@/types/canvas";
|
||||
import type { AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
type CanvasProjectMediaDialogsProps = {
|
||||
cropNode: CanvasNodeData | null;
|
||||
@@ -21,6 +22,7 @@ type CanvasProjectMediaDialogsProps = {
|
||||
onMaskEdit: (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => void;
|
||||
onSplit: (node: CanvasNodeData, params: CanvasImageSplitParams) => void;
|
||||
onUpscale: (node: CanvasNodeData, params: CanvasImageUpscaleParams) => void;
|
||||
config: AiConfig;
|
||||
};
|
||||
|
||||
export function CanvasProjectMediaDialogs({
|
||||
@@ -39,12 +41,13 @@ export function CanvasProjectMediaDialogs({
|
||||
onMaskEdit,
|
||||
onSplit,
|
||||
onUpscale,
|
||||
config,
|
||||
}: CanvasProjectMediaDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
{cropNode?.metadata?.content ? <CanvasNodeCropDialog dataUrl={cropNode.metadata.content} open onClose={onCloseCrop} onConfirm={(crop) => onCrop(cropNode, crop)} /> : null}
|
||||
{annotationNode?.metadata?.content ? <CanvasNodeAnnotationDialog image={{ url: annotationNode.metadata.content, storageKey: annotationNode.metadata.storageKey }} open onClose={onCloseAnnotation} onConfirm={(dataUrl) => onAnnotate(annotationNode, dataUrl)} /> : null}
|
||||
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
|
||||
{maskEditNode?.metadata?.content ? <CanvasNodeMaskEditDialog dataUrl={maskEditNode.metadata.content} config={config} open onClose={onCloseMaskEdit} onConfirm={(payload) => onMaskEdit(maskEditNode, payload)} /> : null}
|
||||
{splitNode?.metadata?.content ? <CanvasNodeSplitDialog dataUrl={splitNode.metadata.content} open onClose={onCloseSplit} onConfirm={(params) => onSplit(splitNode, params)} /> : null}
|
||||
{upscaleNode?.metadata?.content ? <CanvasNodeUpscaleDialog dataUrl={upscaleNode.metadata.content} open onClose={onCloseUpscale} onConfirm={(params) => onUpscale(upscaleNode, params)} /> : null}
|
||||
</>
|
||||
|
||||
@@ -2893,6 +2893,7 @@ function InfiniteCanvasPage() {
|
||||
onMaskEdit={(node, payload) => void maskEditImageNode(node, payload)}
|
||||
onSplit={(node, params) => void splitImageNode(node, params)}
|
||||
onUpscale={(node, params) => void upscaleImageNode(node, params)}
|
||||
config={effectiveConfig}
|
||||
/>
|
||||
|
||||
<CanvasProjectStatusDialogs
|
||||
|
||||
@@ -13,6 +13,9 @@ import type { CanvasVideoSegmentParams } from "@/components/canvas/canvas-video-
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
import { cropDataUrl, splitDataUrl, upscaleDataUrl } from "@/lib/canvas/canvas-image-data";
|
||||
import { audioMetadata, imageMetadata, videoMetadata } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
import { findAvailableGenerationGroupPosition, imageGenerationChildPosition, imageGenerationGroupSize } from "@/lib/canvas/canvas-generation-layout";
|
||||
import { canvasGenerationPromptMetadata } from "@/lib/canvas/canvas-generation-submission";
|
||||
import { cancelIncompleteImageBatch } from "@/lib/canvas/canvas-image-batch-retry";
|
||||
import { buildAngleLabel, buildAnglePrompt, createCanvasNode } from "@/lib/canvas/canvas-project-domain";
|
||||
import { validateVideoSegmentBatch } from "@/lib/canvas/canvas-video-regeneration";
|
||||
import { resolveCanvasStyleExecution } from "@/lib/canvas/canvas-style-execution";
|
||||
@@ -67,6 +70,7 @@ type UseCanvasMediaToolsOptions = {
|
||||
const NODE_STATUS_LOADING = "loading" as const;
|
||||
const NODE_STATUS_SUCCESS = "success" as const;
|
||||
const NODE_STATUS_ERROR = "error" as const;
|
||||
const NODE_STATUS_IDLE = "idle" as const;
|
||||
const IMAGE_PROMPT_REVERSE_PRESET = `请根据参考图片反推一段适合用于 AI 生图的提示词。
|
||||
|
||||
要求:
|
||||
@@ -571,46 +575,152 @@ export function useCanvasMediaTools({
|
||||
|
||||
const maskEditImageNode = useCallback(async (node: CanvasNodeData, payload: CanvasImageMaskEditPayload) => {
|
||||
if (!node.metadata?.content) return;
|
||||
const generationConfig = { ...buildGenerationConfig(effectiveConfig, node, "image"), count: "1", size: node.metadata?.size || "auto" };
|
||||
const baseGenerationConfig = buildGenerationConfig(effectiveConfig, node, "image");
|
||||
const generationConfig = {
|
||||
...baseGenerationConfig,
|
||||
...payload.generationConfig,
|
||||
model: payload.generationConfig?.model || payload.generationConfig?.imageModel || baseGenerationConfig.model,
|
||||
imageModel: payload.generationConfig?.imageModel || payload.generationConfig?.model || effectiveConfig.imageModel,
|
||||
count: String(payload.generationConfig?.count || 1),
|
||||
size: payload.generationConfig?.size || node.metadata?.size || "auto",
|
||||
};
|
||||
if (!isAiConfigReady(generationConfig, generationConfig.model)) {
|
||||
navigateToSettings({ continueCreation: true });
|
||||
return;
|
||||
}
|
||||
const userPrompt = payload.prompt.trim();
|
||||
const prompt = `只修改蒙版透明区域,其他区域保持不变。${userPrompt}`;
|
||||
const childId = nanoid();
|
||||
const source = nodeReferenceImage(node);
|
||||
if (!source) return;
|
||||
const styleExecution = resolveImageEditStyle(node, prompt, generationConfig);
|
||||
if (!styleExecution) return;
|
||||
const { prompt: effectivePrompt, metadata: styleMetadata } = styleExecution;
|
||||
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, 1, [source]);
|
||||
const requestedCount = Math.max(1, Number(generationConfig.count) || 1);
|
||||
const generationMetadata = buildImageGenerationMetadata("edit", generationConfig, requestedCount, [source]);
|
||||
setMaskEditNodeId(null);
|
||||
setRunningNodeId(childId);
|
||||
setNodes((current) => [...current, { id: childId, type: CanvasNodeType.Image, title: userPrompt.slice(0, 32) || "局部编辑结果", position: { x: node.position.x + node.width + 96, y: node.position.y }, width: node.width, height: node.height, metadata: { prompt: effectivePrompt, status: NODE_STATUS_LOADING, ...generationMetadata, ...styleMetadata } }]);
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: childId }]);
|
||||
setSelectedNodeIds(new Set([childId]));
|
||||
const rootId = nanoid();
|
||||
const childIds = requestedCount > 1 ? Array.from({ length: requestedCount }, () => nanoid()) : [];
|
||||
const targetIds = requestedCount > 1 ? childIds : [rootId];
|
||||
const imageSize = { width: node.width, height: node.height };
|
||||
const preferredPosition = { x: node.position.x + node.width + 96, y: node.position.y };
|
||||
const rootPosition = findAvailableGenerationGroupPosition(nodesRef.current, preferredPosition, imageGenerationGroupSize(imageSize, imageSize, childIds.length));
|
||||
const rootNode: CanvasNodeData = {
|
||||
id: rootId,
|
||||
type: CanvasNodeType.Image,
|
||||
title: userPrompt.slice(0, 32) || "局部编辑结果",
|
||||
position: rootPosition,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
metadata: {
|
||||
...canvasGenerationPromptMetadata(userPrompt, effectivePrompt),
|
||||
status: NODE_STATUS_LOADING,
|
||||
isBatchRoot: requestedCount > 1,
|
||||
batchChildIds: requestedCount > 1 ? childIds : undefined,
|
||||
batchFailedCount: requestedCount > 1 ? 0 : undefined,
|
||||
imageBatchExpanded: requestedCount > 1 ? true : undefined,
|
||||
...generationMetadata,
|
||||
...styleMetadata,
|
||||
},
|
||||
};
|
||||
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
||||
id,
|
||||
type: CanvasNodeType.Image,
|
||||
title: `${userPrompt.slice(0, 28) || "局部编辑结果"} · ${index + 1}`,
|
||||
position: imageGenerationChildPosition(rootNode.position, rootNode.width, imageSize, index),
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
metadata: {
|
||||
...canvasGenerationPromptMetadata(userPrompt, effectivePrompt),
|
||||
status: NODE_STATUS_LOADING,
|
||||
batchRootId: rootId,
|
||||
...generationMetadata,
|
||||
...styleMetadata,
|
||||
},
|
||||
}));
|
||||
setRunningNodeId(rootId);
|
||||
setNodes((current) => [...current, rootNode, ...childNodes]);
|
||||
setConnections((current) => [...current, { id: nanoid(), fromNodeId: node.id, toNodeId: rootId }, ...childIds.map((childId) => ({ id: nanoid(), fromNodeId: rootId, toNodeId: childId }))]);
|
||||
setSelectedNodeIds(new Set([rootId, ...childIds]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(childId);
|
||||
const controller = startGenerationRequest(childId, node.id, childId);
|
||||
setDialogNodeId(rootId);
|
||||
const controller = startGenerationRequest(rootId, node.id, rootId);
|
||||
targetIds.forEach((targetId) => startGenerationRequest(targetId, node.id, rootId, controller));
|
||||
let hasSuccess = false;
|
||||
let failureCount = 0;
|
||||
let representativeError: string | undefined;
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({ projectId, nodeId: childId, mode: "image", prompt: effectivePrompt, config: generationConfig, referenceImages: [source], mask: { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl }, signal: controller.signal, metadata: { sourceNodeId: node.id, edit: "mask", ...styleMetadata }, onTaskCreated: (task) => bindGenerationTask(childId, task) });
|
||||
const image = result.images?.[0];
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === childId);
|
||||
if (!currentNode) throw new Error("局部编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => item.id === childId ? finalizedNode : item));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
const details = generationErrorMessage(error);
|
||||
message.error(details);
|
||||
setNodes((current) => current.map((item) => item.id === childId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item));
|
||||
await Promise.all(targetIds.map(async (targetId) => {
|
||||
try {
|
||||
const result = await runBackendCanvasGenerationTask({
|
||||
projectId,
|
||||
nodeId: targetId,
|
||||
mode: "image",
|
||||
prompt: effectivePrompt,
|
||||
config: { ...generationConfig, count: "1" },
|
||||
referenceImages: [source],
|
||||
mask: { id: `${node.id}-mask`, name: "mask.png", type: "image/png", dataUrl: payload.maskDataUrl },
|
||||
signal: controller.signal,
|
||||
metadata: { sourceNodeId: node.id, edit: "mask", ...styleMetadata },
|
||||
onTaskCreated: (task) => bindGenerationTask(targetId, task),
|
||||
});
|
||||
const image = result.images?.find((item) => item?.dataUrl);
|
||||
if (!image?.dataUrl) throw new Error("后端任务没有返回图片");
|
||||
const uploaded = await uploadImage(image.dataUrl);
|
||||
const size = fitNodeSize(uploaded.width, uploaded.height, node.width, node.height);
|
||||
const currentNode = nodesRef.current.find((item) => item.id === targetId);
|
||||
if (!currentNode) throw new Error("局部编辑节点已被删除");
|
||||
const finalizedNode = { ...currentNode, width: size.width, height: size.height, metadata: { ...currentNode.metadata, ...imageMetadata(uploaded), prompt: effectivePrompt, ...generationMetadata } };
|
||||
setNodes((current) => current.map((item) => {
|
||||
if (item.id === targetId) return finalizedNode;
|
||||
if (item.id !== rootId || requestedCount <= 1 || item.metadata?.primaryImageId) return item;
|
||||
return { ...item, width: size.width, height: size.height, metadata: { ...item.metadata, ...imageMetadata(uploaded), primaryImageId: targetId, status: NODE_STATUS_SUCCESS } };
|
||||
}));
|
||||
await persistMediaNodes([finalizedNode]);
|
||||
hasSuccess = true;
|
||||
} catch (error) {
|
||||
if (isGenerationCanceled(error)) return;
|
||||
failureCount += 1;
|
||||
const details = generationErrorMessage(error);
|
||||
representativeError = details;
|
||||
setNodes((current) => current.map((item) => (item.id === targetId ? { ...item, metadata: { ...item.metadata, status: NODE_STATUS_ERROR, errorDetails: details } } : item)));
|
||||
} finally {
|
||||
finishGenerationRequest(targetId, controller);
|
||||
}
|
||||
}));
|
||||
if (controller.signal.aborted) {
|
||||
setNodes((current) => {
|
||||
const cancelled = cancelIncompleteImageBatch(rootId, childIds, current, []);
|
||||
if (cancelled.removedIds.length) {
|
||||
const removed = new Set(cancelled.removedIds);
|
||||
setConnections((connections) => connections.filter((connection) => !removed.has(connection.fromNodeId) && !removed.has(connection.toNodeId)));
|
||||
}
|
||||
return cancelled.nodes.map((item) => {
|
||||
if (item.id !== rootId) return item;
|
||||
if (item.metadata?.content) return item;
|
||||
return { ...item, metadata: { ...item.metadata, status: NODE_STATUS_IDLE, errorDetails: undefined } };
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (failureCount > 0) {
|
||||
message.error(hasSuccess ? "部分局部编辑失败" : representativeError || "局部编辑失败");
|
||||
}
|
||||
setNodes((current) => current.map((item) => {
|
||||
if (item.id !== rootId) return item;
|
||||
return {
|
||||
...item,
|
||||
metadata: {
|
||||
...item.metadata,
|
||||
status: hasSuccess ? NODE_STATUS_SUCCESS : NODE_STATUS_ERROR,
|
||||
batchFailedCount: requestedCount > 1 ? failureCount : undefined,
|
||||
...(hasSuccess
|
||||
? { errorDetails: undefined }
|
||||
: { errorDetails: representativeError || "局部编辑失败" }),
|
||||
},
|
||||
};
|
||||
}));
|
||||
} finally {
|
||||
finishGenerationRequest(childId, controller);
|
||||
if (requestedCount > 1) finishGenerationRequest(rootId, controller);
|
||||
setRunningNodeId(null);
|
||||
}
|
||||
}, [bindGenerationTask, effectiveConfig, finishGenerationRequest, isAiConfigReady, message, nodesRef, persistMediaNodes, projectId, resolveImageEditStyle, setConnections, setDialogNodeId, setNodes, setRunningNodeId, setSelectedConnectionId, setSelectedNodeIds, startGenerationRequest]);
|
||||
|
||||
Reference in New Issue
Block a user