feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -1,254 +1,237 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
Clock3,
|
||||
Cloud,
|
||||
Film,
|
||||
ImageIcon,
|
||||
ImagePlus,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
Settings2,
|
||||
Sparkles,
|
||||
X,
|
||||
WandSparkles,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
|
||||
import {
|
||||
IMAGE_WORKSPACE_CREATE_PROJECT_EVENT,
|
||||
resolveImageWorkspaceAssetUrl,
|
||||
} from '@/lib/image-workspace';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import type {
|
||||
ImageWorkspaceGenerationSettings,
|
||||
ImageWorkspaceImage,
|
||||
ImageWorkspaceMessage,
|
||||
ImageWorkspaceOption,
|
||||
DesignAsset,
|
||||
DesignGenerationQuote,
|
||||
DesignGenerationTask,
|
||||
DesignMessage,
|
||||
DesignTaskStatus,
|
||||
} from '../../../shared/image-workspace';
|
||||
|
||||
function resolveOptionId(
|
||||
currentId: string | undefined,
|
||||
preferredId: string | undefined,
|
||||
options: ImageWorkspaceOption[],
|
||||
): string | undefined {
|
||||
if (currentId && options.some((option) => option.id === currentId)) return currentId;
|
||||
if (preferredId && options.some((option) => option.id === preferredId)) return preferredId;
|
||||
return options[0]?.id;
|
||||
const ACTIVE_TASK_STATUSES = new Set<DesignTaskStatus>(['queued', 'running']);
|
||||
|
||||
function taskStatusLabel(status: DesignTaskStatus): string {
|
||||
if (status === 'queued') return '排队中';
|
||||
if (status === 'running') return '生成中';
|
||||
if (status === 'succeeded') return '已完成';
|
||||
if (status === 'cancelled') return '已取消';
|
||||
return '失败';
|
||||
}
|
||||
|
||||
function messageStatusLabel(message: ImageWorkspaceMessage): string | null {
|
||||
if (message.status === 'queued') return '排队中';
|
||||
if (message.status === 'running') return '生成中';
|
||||
if (message.status === 'failed') return '生成失败';
|
||||
function mediumLabel(medium: 'image' | 'video'): string {
|
||||
return medium === 'video' ? '视频' : '图片';
|
||||
}
|
||||
|
||||
function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null {
|
||||
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
||||
const quote = messages[index].generationQuote;
|
||||
if (quote?.status === 'active') return quote;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fileToBase64(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onerror = () => reject(reader.error ?? new Error('无法读取参考图'));
|
||||
reader.onload = () => {
|
||||
const result = typeof reader.result === 'string' ? reader.result : '';
|
||||
const separator = result.indexOf(',');
|
||||
resolve(separator >= 0 ? result.slice(separator + 1) : result);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
function AssetPreview({ asset }: { asset: DesignAsset }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveImageWorkspaceAssetUrl(asset.contentPath).then((nextUrl) => {
|
||||
if (!cancelled) setUrl(nextUrl);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [asset.contentPath]);
|
||||
|
||||
if (!url) {
|
||||
return (
|
||||
<div role="status" className="flex aspect-video items-center justify-center bg-surface-subtle">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-brand" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (asset.mimeType.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
controls
|
||||
preload="metadata"
|
||||
src={url}
|
||||
className="aspect-video w-full bg-black object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function OptionSelect({
|
||||
label,
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
options: ImageWorkspaceOption[];
|
||||
onChange: (value: string) => void;
|
||||
}) {
|
||||
if (options.length === 0) return null;
|
||||
return (
|
||||
<label className="grid min-w-0 gap-1.5 text-[11px] font-semibold text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<Select
|
||||
aria-label={label}
|
||||
value={value ?? ''}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
className="h-9 rounded-lg border-border/70 bg-surface-input px-2 text-xs font-medium text-foreground"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.label}</option>
|
||||
))}
|
||||
</Select>
|
||||
</label>
|
||||
<img
|
||||
src={url}
|
||||
alt="AI 设计生成结果"
|
||||
className="aspect-video w-full bg-surface-subtle object-contain"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageStatus({ message }: { message: ImageWorkspaceMessage }) {
|
||||
const label = messageStatusLabel(message);
|
||||
if (!label) return null;
|
||||
|
||||
function TaskCard({ task }: { task: DesignGenerationTask }) {
|
||||
const active = ACTIVE_TASK_STATUSES.has(task.status);
|
||||
return (
|
||||
<span
|
||||
role="status"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-md bg-surface-tertiary px-2 py-0.5 text-[10px] font-semibold',
|
||||
message.status === 'failed' ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
<article
|
||||
data-testid={`design-task-${task.taskId}`}
|
||||
className="overflow-hidden rounded-2xl border border-border/70 bg-background shadow-soft"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
message.status === 'failed' ? 'bg-destructive' : 'bg-brand',
|
||||
(message.status === 'queued' || message.status === 'running') && 'animate-pulse',
|
||||
)}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
{task.resultAssets.map((asset) => (
|
||||
<AssetPreview key={asset.assetId} asset={asset} />
|
||||
))}
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold">
|
||||
{task.medium === 'video'
|
||||
? <Film className="h-3.5 w-3.5 text-brand" />
|
||||
: <ImageIcon className="h-3.5 w-3.5 text-brand" />}
|
||||
{mediumLabel(task.medium)}任务
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-2 py-1 text-[10px] font-semibold',
|
||||
task.status === 'succeeded' && 'bg-emerald-50 text-emerald-700',
|
||||
task.status === 'failed' && 'bg-destructive/10 text-destructive',
|
||||
active && 'bg-brand-soft text-brand',
|
||||
task.status === 'cancelled' && 'bg-surface-tertiary text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{active ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
|
||||
{task.status === 'succeeded' ? <Check className="h-3 w-3" /> : null}
|
||||
{taskStatusLabel(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 text-xs font-medium leading-5 text-muted-foreground">
|
||||
{task.briefSummary}
|
||||
</p>
|
||||
{task.failureCode ? (
|
||||
<p role="alert" className="mt-2 rounded-lg bg-destructive/10 px-2 py-1.5 text-[11px] font-semibold text-destructive">
|
||||
{task.failureCode}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
function QuoteCard({
|
||||
quote,
|
||||
busy,
|
||||
onConfirm,
|
||||
}: {
|
||||
quote: DesignGenerationQuote;
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={`design-quote-${quote.quoteId}`}
|
||||
className="mt-3 rounded-2xl border border-brand/25 bg-brand-soft/60 p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-foreground">
|
||||
<WandSparkles className="h-4 w-4 text-brand" />
|
||||
{mediumLabel(quote.medium)}生成方案
|
||||
</span>
|
||||
<span className="rounded-full bg-background px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||||
{quote.quotedDesignPoints} 设计点
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium leading-5 text-muted-foreground">
|
||||
{quote.briefSummary}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="mt-3 h-8 rounded-full bg-brand px-3 text-xs font-semibold text-primary-foreground"
|
||||
onClick={onConfirm}
|
||||
disabled={busy}
|
||||
>
|
||||
{busy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" /> : <Sparkles className="mr-1.5 h-3.5 w-3.5" />}
|
||||
确认并开始生成
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageCanvas() {
|
||||
const status = useImageWorkspaceStore((state) => state.status);
|
||||
const snapshot = useImageWorkspaceStore((state) => state.snapshot);
|
||||
const activeProjectId = useImageWorkspaceStore((state) => state.activeProjectId);
|
||||
const activeAgentIds = useImageWorkspaceStore((state) => state.activeAgentIds);
|
||||
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
|
||||
const workspace = useImageWorkspaceStore((state) => state.workspace);
|
||||
const tasks = useImageWorkspaceStore((state) => state.tasks);
|
||||
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
||||
const load = useImageWorkspaceStore((state) => state.load);
|
||||
const addAgent = useImageWorkspaceStore((state) => state.addAgent);
|
||||
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
|
||||
const refreshTasks = useImageWorkspaceStore((state) => state.refreshTasks);
|
||||
const sendMessage = useImageWorkspaceStore((state) => state.sendMessage);
|
||||
const uploadReference = useImageWorkspaceStore((state) => state.uploadReference);
|
||||
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [settings, setSettings] = useState<ImageWorkspaceGenerationSettings>({});
|
||||
const [selectedReferences, setSelectedReferences] = useState<ImageWorkspaceImage[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [addingAgent, setAddingAgent] = useState(false);
|
||||
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const conversationEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const activeProject = useMemo(
|
||||
() => snapshot?.projects.find((project) => project.id === activeProjectId) ?? null,
|
||||
[activeProjectId, snapshot],
|
||||
const quote = useMemo(
|
||||
() => workspace ? activeQuote(workspace.messages) : null,
|
||||
[workspace],
|
||||
);
|
||||
const activeAgentId = activeProject ? activeAgentIds[activeProject.id] : undefined;
|
||||
const activeAgent = activeProject?.agents.find((agent) => agent.id === activeAgentId) ?? null;
|
||||
const capabilities = snapshot?.capabilities;
|
||||
const hasActiveTasks = tasks.some((task) => ACTIVE_TASK_STATUSES.has(task.status));
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'idle') void load();
|
||||
}, [load, status]);
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt('');
|
||||
setSelectedReferences([]);
|
||||
setActionError(null);
|
||||
}, [activeProjectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!capabilities) return;
|
||||
setSettings((current) => ({
|
||||
modeId: resolveOptionId(current.modeId, capabilities.defaultModeId, capabilities.modes),
|
||||
modelId: resolveOptionId(current.modelId, capabilities.defaultModelId, capabilities.models),
|
||||
aspectRatioId: resolveOptionId(
|
||||
current.aspectRatioId,
|
||||
capabilities.defaultAspectRatioId,
|
||||
capabilities.aspectRatios,
|
||||
),
|
||||
resolutionId: resolveOptionId(
|
||||
current.resolutionId,
|
||||
capabilities.defaultResolutionId,
|
||||
capabilities.resolutions,
|
||||
),
|
||||
outputCountId: resolveOptionId(
|
||||
current.outputCountId,
|
||||
capabilities.defaultOutputCountId,
|
||||
capabilities.outputCounts,
|
||||
),
|
||||
}));
|
||||
}, [capabilities]);
|
||||
if (!hasActiveTasks || !workspace) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshTasks().catch(() => undefined);
|
||||
}, 2_500);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasActiveTasks, refreshTasks, workspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof conversationEndRef.current?.scrollIntoView === 'function') {
|
||||
conversationEndRef.current.scrollIntoView({ block: 'end' });
|
||||
}
|
||||
}, [activeProject?.messages.length]);
|
||||
}, [workspace?.messages.length]);
|
||||
|
||||
const addReference = (image: ImageWorkspaceImage) => {
|
||||
if (!capabilities || capabilities.maxReferenceImages <= 0) {
|
||||
setActionError('当前创作空间未开放参考图功能');
|
||||
return;
|
||||
}
|
||||
setSelectedReferences((current) => {
|
||||
if (current.some((item) => item.id === image.id)) return current;
|
||||
if (current.length >= capabilities.maxReferenceImages) {
|
||||
setActionError(`最多添加 ${capabilities.maxReferenceImages} 张参考图`);
|
||||
return current;
|
||||
}
|
||||
setActionError(null);
|
||||
return [...current, image];
|
||||
});
|
||||
};
|
||||
|
||||
const handleUploadReference = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const input = event.currentTarget;
|
||||
const file = input.files?.[0];
|
||||
if (!file) return;
|
||||
if (!activeProject || !capabilities) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
const upload = capabilities.referenceUpload;
|
||||
if (!upload.enabled) {
|
||||
setActionError('当前创作空间暂未开放参考图上传');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (upload.acceptedMimeTypes.length > 0 && !upload.acceptedMimeTypes.includes(file.type)) {
|
||||
setActionError('请选择当前创作空间支持的图片格式');
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
if (upload.maxBytes && file.size > upload.maxBytes) {
|
||||
setActionError(`参考图大小不能超过 ${Math.ceil(upload.maxBytes / 1024 / 1024)} MB`);
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const result = await uploadReference({
|
||||
projectId: activeProject.id,
|
||||
fileName: file.name,
|
||||
mimeType: file.type,
|
||||
contentBase64: await fileToBase64(file),
|
||||
});
|
||||
addReference(result.reference);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setUploading(false);
|
||||
input.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!activeProject || !activeAgent || !prompt.trim()) return;
|
||||
const handleSend = async (override?: string) => {
|
||||
const message = (override ?? prompt).trim();
|
||||
if (!workspace || !message || submitting) return;
|
||||
setSubmitting(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await sendMessage({
|
||||
projectId: activeProject.id,
|
||||
agentId: activeAgent.id,
|
||||
prompt: prompt.trim(),
|
||||
referenceImageIds: selectedReferences.map((image) => image.id),
|
||||
settings,
|
||||
});
|
||||
await sendMessage(message);
|
||||
setPrompt('');
|
||||
setSelectedReferences([]);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -256,6 +239,19 @@ export function ImageCanvas() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirm = async (quoteId: string) => {
|
||||
if (!workspace || confirmingQuoteId) return;
|
||||
setConfirmingQuoteId(quoteId);
|
||||
setActionError(null);
|
||||
try {
|
||||
await confirmGeneration(quoteId);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setConfirmingQuoteId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -263,19 +259,6 @@ export function ImageCanvas() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddAgent = async () => {
|
||||
if (!activeProject) return;
|
||||
setAddingAgent(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
await addAgent(activeProject.id);
|
||||
} catch (error) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setAddingAgent(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openCreateProject = () => {
|
||||
window.dispatchEvent(new Event(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT));
|
||||
};
|
||||
@@ -285,36 +268,25 @@ export function ImageCanvas() {
|
||||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||||
<div role="status" className="surface-card flex items-center gap-3 rounded-2xl border border-border/70 bg-background px-5 py-4 text-sm font-semibold shadow-float">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-brand" />
|
||||
<span>正在读取创作空间</span>
|
||||
<span>正在读取 AI 设计项目</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unavailable' || status === 'error' || !snapshot) {
|
||||
if (status === 'unavailable' || status === 'error' || !bootstrap) {
|
||||
return (
|
||||
<div
|
||||
data-testid="image-canvas-page"
|
||||
className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6"
|
||||
>
|
||||
<section
|
||||
data-testid="image-workspace-unavailable"
|
||||
className="surface-card w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-float"
|
||||
>
|
||||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||||
<section data-testid="image-workspace-unavailable" className="surface-card w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-float">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-tertiary text-foreground">
|
||||
<Cloud className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-[-0.025em]">创作空间暂不可用</h1>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-[-0.025em]">AI 设计暂不可用</h1>
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm font-medium leading-6 text-muted-foreground">
|
||||
暂时无法连接创作空间,请稍后重试。
|
||||
暂时无法连接设计服务,请检查登录状态或稍后重试。
|
||||
</p>
|
||||
{workspaceError ? <p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-medium text-destructive">{workspaceError}</p> : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-5 border-brand/20 bg-brand-soft font-semibold text-foreground hover:bg-brand-soft/70"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
<Button type="button" variant="outline" className="mt-5" onClick={() => void load()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
</Button>
|
||||
@@ -323,61 +295,52 @@ export function ImageCanvas() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!activeProject) {
|
||||
if (bootstrap.workspaces.length === 0) {
|
||||
return (
|
||||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||||
<section className="surface-card w-full max-w-md rounded-2xl border border-border/70 bg-background p-8 text-center shadow-float">
|
||||
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||||
<ImageIcon className="h-6 w-6" />
|
||||
<WandSparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-[-0.025em]">创建第一个项目</h1>
|
||||
<h1 className="mt-5 text-2xl font-semibold tracking-[-0.025em]">创建第一个设计项目</h1>
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm font-medium leading-6 text-muted-foreground">
|
||||
项目会持续保留对话和作品。创建后请手动添加一个 Agent,再开始创作。
|
||||
每个项目会持续保留与设计 Agent 的对话、确认过的方向,以及图片和视频生成任务。
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
className="mt-5 border border-brand/20 bg-brand font-semibold text-primary-foreground"
|
||||
onClick={openCreateProject}
|
||||
>
|
||||
<Button type="button" className="mt-5 bg-brand text-primary-foreground" onClick={openCreateProject}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
新建项目
|
||||
新建设计项目
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const canAddReference = Boolean(capabilities && capabilities.maxReferenceImages > 0);
|
||||
const canUploadReference = Boolean(
|
||||
capabilities?.referenceUpload.enabled
|
||||
&& selectedReferences.length < capabilities.maxReferenceImages,
|
||||
);
|
||||
if (!workspace) {
|
||||
return (
|
||||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full items-center justify-center bg-background p-6 text-foreground sm:-m-6">
|
||||
<Loader2 role="status" className="h-6 w-6 animate-spin text-brand" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="image-canvas-page"
|
||||
className="-m-5 flex min-h-full min-w-0 flex-col overflow-hidden bg-background text-foreground sm:-m-6"
|
||||
>
|
||||
<div data-testid="image-workspace-page" className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<header
|
||||
data-testid="image-workspace-header"
|
||||
className="glass-surface flex shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/70 px-4 py-3 sm:px-6"
|
||||
>
|
||||
<div data-testid="image-canvas-page" className="-m-5 flex min-h-full min-w-0 flex-col overflow-hidden bg-background text-foreground sm:-m-6">
|
||||
<header className="glass-surface flex shrink-0 items-center justify-between gap-3 border-b border-border/70 bg-background/80 px-4 py-3 sm:px-6">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-brand-soft text-brand">
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
<WandSparkles className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h1 className="truncate text-base font-semibold tracking-[-0.015em]">{activeProject.name}</h1>
|
||||
<span className="hidden shrink-0 rounded-md bg-surface-tertiary px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground sm:inline-flex">
|
||||
Canvas
|
||||
<h1 className="truncate text-base font-semibold tracking-[-0.015em]">{workspace.title}</h1>
|
||||
<span className="hidden rounded-md bg-surface-tertiary px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground sm:inline-flex">
|
||||
AI Design
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-0.5 flex min-w-0 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||
<Bot className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="truncate">
|
||||
{activeAgent ? `当前 Agent:${activeAgent.name}` : '当前项目还没有 Agent'}
|
||||
{workspace.brief.ready ? workspace.brief.summary : '设计 Agent 正在与你确认创作方向'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
@@ -386,235 +349,144 @@ export function ImageCanvas() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="刷新创作空间"
|
||||
title="刷新创作空间"
|
||||
className="h-9 w-9 shrink-0 rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={() => void load()}
|
||||
aria-label="刷新设计项目"
|
||||
className="h-9 w-9 rounded-full"
|
||||
onClick={() => {
|
||||
void Promise.all([refreshWorkspace(), refreshTasks()]).catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<main
|
||||
data-testid="image-workspace-conversation"
|
||||
className="relative min-h-0 flex-1 overflow-y-auto bg-background px-4 py-5 sm:px-6 sm:py-7"
|
||||
>
|
||||
<div className="mx-auto flex min-h-full w-full max-w-4xl flex-col pb-4 sm:pb-8">
|
||||
{activeProject.messages.length === 0 ? (
|
||||
<div
|
||||
data-testid="image-workspace-empty-state"
|
||||
className="chat-empty-state flex flex-1 items-center justify-center px-1 py-10 sm:px-4"
|
||||
>
|
||||
<div className="w-full max-w-2xl text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-2xl font-medium tracking-[-0.025em] sm:text-[28px]">
|
||||
从一段描述开始创作
|
||||
</h2>
|
||||
<p className="mx-auto mt-3 max-w-xl text-sm font-medium leading-6 text-muted-foreground">
|
||||
描述画面,或先添加参考图。生成结果会直接出现在当前对话中。
|
||||
</p>
|
||||
<p className="mt-5 text-xs font-medium text-muted-foreground/75">
|
||||
按 ⌘/Ctrl + Enter 生成
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-full flex-col gap-8">
|
||||
{activeProject.messages.map((message) => {
|
||||
const statusLabel = messageStatusLabel(message);
|
||||
const assistant = message.role === 'assistant';
|
||||
return (
|
||||
<article
|
||||
key={message.id}
|
||||
className={cn('flex gap-3', assistant ? 'justify-start' : 'justify-end')}
|
||||
>
|
||||
{assistant ? (
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-md border border-border/70 bg-surface-subtle text-foreground">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('min-w-0', assistant ? 'w-full' : 'max-w-[88%] sm:max-w-[78%]')}>
|
||||
<div className={cn(
|
||||
'mb-2 flex items-center gap-2 text-[11px] font-semibold text-muted-foreground',
|
||||
!assistant && 'justify-end',
|
||||
)}>
|
||||
<span>{assistant ? 'AI 创作 Agent' : '你'}</span>
|
||||
<MessageStatus message={message} />
|
||||
</div>
|
||||
<div className={cn(
|
||||
assistant
|
||||
? 'chat-assistant-message-surface'
|
||||
: 'chat-user-message-surface rounded-2xl px-4 py-3',
|
||||
)}>
|
||||
{message.text ? <p className="whitespace-pre-wrap text-sm font-medium leading-6">{message.text}</p> : null}
|
||||
{message.images.length > 0 ? (
|
||||
<div className={cn('grid gap-3', message.text && 'mt-3', message.images.length > 1 && 'sm:grid-cols-2')}>
|
||||
{message.images.map((image) => (
|
||||
<figure key={image.id} className="overflow-hidden rounded-2xl border border-border/70 bg-surface-subtle shadow-soft">
|
||||
<img
|
||||
src={image.thumbnailUrl || image.url}
|
||||
alt={image.alt || '生成图片'}
|
||||
className="min-h-40 max-h-[min(62vh,40rem)] w-full bg-surface-subtle object-contain"
|
||||
/>
|
||||
{assistant ? (
|
||||
<figcaption className="flex flex-wrap gap-2 border-t border-border/70 bg-background/70 p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-full border-border/70 bg-background px-3 text-xs font-semibold"
|
||||
onClick={() => addReference(image)}
|
||||
disabled={!canAddReference}
|
||||
>
|
||||
继续编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3 text-xs font-semibold"
|
||||
onClick={() => addReference(image)}
|
||||
disabled={!canAddReference}
|
||||
>
|
||||
用作参考
|
||||
</Button>
|
||||
</figcaption>
|
||||
) : null}
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{!message.text && message.images.length === 0 && statusLabel ? (
|
||||
<div className="flex items-center gap-2 rounded-xl bg-surface-subtle px-3 py-2 text-sm font-medium text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-brand" />
|
||||
{statusLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{message.error ? <p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">{message.error}</p> : null}
|
||||
</div>
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col lg:flex-row">
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto px-4 py-5 sm:px-6">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4">
|
||||
{workspace.messages.length === 0 ? (
|
||||
<div data-testid="image-workspace-empty-state" className="flex flex-1 items-center justify-center py-10 text-center">
|
||||
<div className="max-w-xl">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-2xl bg-brand-soft text-brand">
|
||||
<Sparkles className="h-6 w-6" />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div ref={conversationEndRef} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<form
|
||||
data-testid="image-workspace-composer"
|
||||
className="shrink-0 bg-background px-4 pb-4 pt-2 sm:px-0 sm:pb-5"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[46rem]">
|
||||
{!activeAgent ? (
|
||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 rounded-xl border border-amber-400/30 bg-amber-50 px-3 py-2.5 text-xs font-semibold text-amber-900">
|
||||
<span>当前项目没有可用 Agent,请先添加一个 Agent。</span>
|
||||
<Button type="button" size="sm" variant="outline" className="h-8 border-amber-400/40 bg-background text-amber-900 hover:bg-amber-100" onClick={() => void handleAddAgent()} disabled={addingAgent}>
|
||||
{addingAgent ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Plus className="mr-2 h-4 w-4" />}
|
||||
添加 Agent
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
data-testid="image-workspace-dropzone"
|
||||
className="chat-composer-surface relative flex min-w-0 flex-col gap-2 rounded-2xl border border-border/70 bg-background p-3 text-foreground shadow-soft focus-within:ring-1 focus-within:ring-foreground/10"
|
||||
>
|
||||
{selectedReferences.length > 0 ? (
|
||||
<div data-testid="image-workspace-selected-references" className="flex flex-wrap gap-2" aria-label="已添加到本次创作的参考图">
|
||||
{selectedReferences.map((image) => (
|
||||
<div key={image.id} className="flex min-w-0 max-w-full items-center gap-2 rounded-xl border border-border/70 bg-surface-subtle p-1.5 pr-2">
|
||||
<img src={image.thumbnailUrl || image.url} alt="已选参考图" className="h-9 w-9 rounded-lg object-cover" />
|
||||
<span className="max-w-36 truncate text-[11px] font-semibold">{image.alt || '参考图'}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="移除参考图"
|
||||
className="h-6 w-6 rounded-full text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setSelectedReferences((current) => current.filter((item) => item.id !== image.id))}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<h2 className="mt-5 text-2xl font-medium tracking-[-0.025em]">先聊清楚,再开始创作</h2>
|
||||
<p className="mx-auto mt-3 text-sm font-medium leading-6 text-muted-foreground">
|
||||
告诉设计 Agent 你的用途、受众和想表达的感觉。它会帮助你收敛方向,确认后再创建生图或生视频任务。
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-7">
|
||||
{workspace.messages.map((message) => {
|
||||
const assistant = message.role === 'assistant';
|
||||
return (
|
||||
<article key={message.id} className={cn('flex gap-3', assistant ? 'justify-start' : 'justify-end')}>
|
||||
{assistant ? (
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-surface-subtle">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
) : null}
|
||||
<div className={cn('min-w-0', assistant ? 'w-full max-w-2xl' : 'max-w-[85%]')}>
|
||||
<div className={cn('mb-2 text-[11px] font-semibold text-muted-foreground', !assistant && 'text-right')}>
|
||||
{assistant ? '设计 Agent' : '你'}
|
||||
</div>
|
||||
<div className={assistant ? 'chat-assistant-message-surface' : 'chat-user-message-surface rounded-2xl px-4 py-3'}>
|
||||
<p className="whitespace-pre-wrap text-sm font-medium leading-6">{message.text}</p>
|
||||
{message.generationQuote?.status === 'active' ? (
|
||||
<QuoteCard
|
||||
quote={message.generationQuote}
|
||||
busy={confirmingQuoteId === message.generationQuote.quoteId}
|
||||
onConfirm={() => void handleConfirm(message.generationQuote!.quoteId)}
|
||||
/>
|
||||
) : null}
|
||||
{assistant && message.quickReplies.length > 0 ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{message.quickReplies.map((reply) => (
|
||||
<Button
|
||||
key={reply}
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 rounded-full px-3 text-xs font-semibold"
|
||||
onClick={() => {
|
||||
if (reply.includes('确认') && quote) {
|
||||
void handleConfirm(quote.quoteId);
|
||||
} else {
|
||||
setPrompt(reply);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reply}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
<div ref={conversationEndRef} aria-hidden="true" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Textarea
|
||||
aria-label="创作描述"
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setActionError(null);
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
rows={3}
|
||||
placeholder="描述你想创作的画面,也可以添加参考图…"
|
||||
className="max-h-40 !min-h-[72px] resize-none border-0 bg-transparent p-0 text-sm font-medium leading-6 shadow-none placeholder:text-muted-foreground/70 focus-visible:border-transparent focus-visible:ring-0"
|
||||
/>
|
||||
|
||||
{actionError ? <p role="alert" className="rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">{actionError}</p> : null}
|
||||
|
||||
<div data-testid="image-workspace-composer-actions" className="flex flex-wrap items-center justify-between gap-2 border-t border-border/70 pt-2">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
aria-label="选择参考图"
|
||||
accept={capabilities?.referenceUpload.acceptedMimeTypes.join(',')}
|
||||
onChange={(event) => void handleUploadReference(event)}
|
||||
<form
|
||||
data-testid="image-workspace-composer"
|
||||
className="shrink-0 bg-background px-4 pb-4 pt-2 sm:px-6"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
}}
|
||||
>
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="chat-composer-surface rounded-2xl border border-border/70 bg-background p-3 shadow-soft focus-within:ring-1 focus-within:ring-foreground/10">
|
||||
<Textarea
|
||||
aria-label="设计需求"
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setActionError(null);
|
||||
}}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
rows={3}
|
||||
placeholder="告诉设计 Agent:要做什么、给谁看、希望是什么感觉…"
|
||||
className="max-h-40 !min-h-[72px] resize-none border-0 bg-transparent p-0 text-sm font-medium leading-6 shadow-none focus-visible:ring-0"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label="添加参考图"
|
||||
title="添加参考图"
|
||||
className="h-8 rounded-full px-2.5 text-xs font-semibold"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!canUploadReference || uploading}
|
||||
>
|
||||
{uploading ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <ImagePlus className="mr-1.5 h-4 w-4" />}
|
||||
添加参考图
|
||||
</Button>
|
||||
<span className="hidden px-1 text-[10px] font-medium text-muted-foreground/75 sm:inline">
|
||||
⌘/Ctrl + Enter 生成
|
||||
</span>
|
||||
{actionError ? <p role="alert" className="mt-2 rounded-xl bg-destructive/10 px-3 py-2 text-xs font-semibold text-destructive">{actionError}</p> : null}
|
||||
<div className="mt-2 flex items-center justify-between gap-3 border-t border-border/70 pt-2">
|
||||
<span className="text-[10px] font-medium text-muted-foreground">⌘/Ctrl + Enter 发送</span>
|
||||
<Button type="submit" className="h-9 rounded-full bg-brand px-4 text-primary-foreground" disabled={submitting || !prompt.trim()}>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
发送给设计 Agent
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
className="h-9 rounded-full border border-brand/20 bg-brand px-4 font-semibold text-primary-foreground"
|
||||
disabled={submitting || !activeAgent || !prompt.trim()}
|
||||
>
|
||||
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Send className="mr-2 h-4 w-4" />}
|
||||
生成图片
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div data-testid="image-workspace-generation-controls" className="border-t border-border/70 pt-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] font-semibold text-muted-foreground">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
生成设置
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
|
||||
<OptionSelect label="创作模式" value={settings.modeId} options={capabilities?.modes ?? []} onChange={(modeId) => setSettings((current) => ({ ...current, modeId }))} />
|
||||
<OptionSelect label="模型" value={settings.modelId} options={capabilities?.models ?? []} onChange={(modelId) => setSettings((current) => ({ ...current, modelId }))} />
|
||||
<OptionSelect label="画幅" value={settings.aspectRatioId} options={capabilities?.aspectRatios ?? []} onChange={(aspectRatioId) => setSettings((current) => ({ ...current, aspectRatioId }))} />
|
||||
<OptionSelect label="分辨率" value={settings.resolutionId} options={capabilities?.resolutions ?? []} onChange={(resolutionId) => setSettings((current) => ({ ...current, resolutionId }))} />
|
||||
<OptionSelect label="生成数量" value={settings.outputCountId} options={capabilities?.outputCounts ?? []} onChange={(outputCountId) => setSettings((current) => ({ ...current, outputCountId }))} />
|
||||
</div>
|
||||
<aside data-testid="design-task-list" className="min-h-0 w-full shrink-0 border-t border-border/70 bg-surface-subtle/45 lg:w-80 lg:border-l lg:border-t-0">
|
||||
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold">生成任务</h2>
|
||||
<p className="mt-0.5 text-[10px] font-medium text-muted-foreground">图片与视频任务统一展示</p>
|
||||
</div>
|
||||
{hasActiveTasks ? <Clock3 className="h-4 w-4 animate-pulse text-brand" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div className="max-h-72 space-y-3 overflow-y-auto p-3 lg:max-h-none lg:h-[calc(100%-57px)]">
|
||||
{tasks.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-border/80 bg-background/70 px-4 py-7 text-center">
|
||||
<WandSparkles className="mx-auto h-5 w-5 text-muted-foreground" />
|
||||
<p className="mt-3 text-xs font-semibold">还没有生成任务</p>
|
||||
<p className="mt-1 text-[11px] font-medium leading-5 text-muted-foreground">
|
||||
与 Agent 确认设计方向后,任务会出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
) : tasks.map((task) => <TaskCard key={task.taskId} task={task} />)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user