完善客户端模块与工作区能力
This commit is contained in:
@@ -7,7 +7,6 @@ import {
|
||||
type KeyboardEvent,
|
||||
} from 'react';
|
||||
import {
|
||||
Bot,
|
||||
Check,
|
||||
Clock3,
|
||||
Cloud,
|
||||
@@ -16,7 +15,7 @@ import {
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Maximize2,
|
||||
MessageSquarePlus,
|
||||
Mic,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Send,
|
||||
@@ -27,7 +26,6 @@ import {
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
@@ -46,6 +44,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useImageWorkspaceStore } from '@/stores/image-workspace';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
import type {
|
||||
DesignAsset,
|
||||
DesignGenerationQuote,
|
||||
@@ -85,6 +84,16 @@ function isGenerationConfirmationIntent(message: string): boolean {
|
||||
return GENERATION_CONFIRMATION_INTENTS.has(normalized);
|
||||
}
|
||||
|
||||
function isQuoteActionReply(reply: string): boolean {
|
||||
return isGenerationConfirmationIntent(reply) || reply.trim() === '继续调整';
|
||||
}
|
||||
|
||||
function shouldShowQuickReplies(message: RenderedDesignMessage): boolean {
|
||||
if (message.role !== 'assistant' || message.quickReplies.length === 0) return false;
|
||||
const hasQuoteActionReply = message.quickReplies.some(isQuoteActionReply);
|
||||
return !hasQuoteActionReply || message.generationQuote?.status === 'active';
|
||||
}
|
||||
|
||||
function taskStatusLabel(status: DesignTaskStatus): string {
|
||||
if (status === 'queued') return '排队中';
|
||||
if (status === 'running') return '生成中';
|
||||
@@ -111,6 +120,132 @@ function activeQuote(messages: DesignMessage[]): DesignGenerationQuote | null {
|
||||
|
||||
type RenderedDesignMessage = DesignMessage & { streaming?: boolean };
|
||||
|
||||
type UploadedReferenceAsset = {
|
||||
asset: DesignAsset;
|
||||
fileName: string;
|
||||
};
|
||||
|
||||
type ReferenceMentionTrigger = {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
};
|
||||
|
||||
function referenceMentionToken(fileName: string): string {
|
||||
const normalized = fileName.trim().replace(/\s+/gu, '-');
|
||||
return `@${normalized || '参考图'}`;
|
||||
}
|
||||
|
||||
function getReferenceMentionTrigger(
|
||||
value: string,
|
||||
cursor: number,
|
||||
): ReferenceMentionTrigger | null {
|
||||
const beforeCursor = value.slice(0, cursor);
|
||||
const match = beforeCursor.match(/(^|\s)@([^\s@]*)$/u);
|
||||
if (!match) return null;
|
||||
const boundaryLength = match[1]?.length ?? 0;
|
||||
return {
|
||||
start: cursor - match[0].length + boundaryLength,
|
||||
end: cursor,
|
||||
query: match[2] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function hasReferenceMention(value: string, token: string): boolean {
|
||||
return value.split(/\s+/u).includes(token);
|
||||
}
|
||||
|
||||
function removeReferenceMention(value: string, token: string): string {
|
||||
const escapedToken = token.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&');
|
||||
return value
|
||||
.replace(new RegExp(`(^|\\s)${escapedToken}(?=\\s|$)`, 'gu'), '$1')
|
||||
.replace(/^[ \t]+/u, '');
|
||||
}
|
||||
|
||||
function ReferenceAssetThumbnail({
|
||||
asset,
|
||||
fileName,
|
||||
className,
|
||||
}: {
|
||||
asset: DesignAsset;
|
||||
fileName: string;
|
||||
className: string;
|
||||
}) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void resolveImageWorkspaceAssetUrl(asset.contentPath)
|
||||
.then((nextUrl) => {
|
||||
if (!cancelled) setUrl(nextUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setUrl(null);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [asset.contentPath]);
|
||||
|
||||
return (
|
||||
<span className={cn('flex shrink-0 items-center justify-center overflow-hidden bg-surface-subtle', className)}>
|
||||
{url ? (
|
||||
<img
|
||||
src={url}
|
||||
alt={`参考图预览:${fileName}`}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : <ImageIcon className="h-4 w-4 text-muted-foreground" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ReferenceAssetCandidate({
|
||||
reference,
|
||||
selected,
|
||||
onToggle,
|
||||
onRemove,
|
||||
}: {
|
||||
reference: UploadedReferenceAsset;
|
||||
selected: boolean;
|
||||
onToggle(): void;
|
||||
onRemove(): void;
|
||||
}) {
|
||||
const { asset, fileName } = reference;
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-1 rounded-xl border border-border/70 bg-background p-1 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={selected ? `取消选择参考图 ${fileName}` : `选择参考图 ${fileName}`}
|
||||
aria-pressed={selected}
|
||||
className={cn(
|
||||
'flex min-w-0 items-center gap-2 rounded-lg px-1.5 py-1 text-left text-xs font-semibold transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
selected && 'bg-brand-soft text-brand',
|
||||
)}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ReferenceAssetThumbnail
|
||||
asset={asset}
|
||||
fileName={fileName}
|
||||
className="h-8 w-8 rounded-md"
|
||||
/>
|
||||
<span className="min-w-0 max-w-36 truncate">{fileName}</span>
|
||||
{selected ? <Check className="h-3.5 w-3.5 shrink-0 text-brand" /> : null}
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label={`移除参考图 ${fileName}`}
|
||||
className="h-7 w-7 shrink-0 rounded-lg text-muted-foreground hover:text-foreground"
|
||||
onClick={onRemove}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssetPreview({ asset }: { asset: DesignAsset }) {
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [previewOpen, setPreviewOpen] = useState(false);
|
||||
@@ -296,7 +431,7 @@ function ImageSourceAssetOption({
|
||||
) : <Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 p-3">
|
||||
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{asset.width} × {asset.height}
|
||||
</span>
|
||||
<Button
|
||||
@@ -346,7 +481,7 @@ function TaskCard({ task }: { task: DesignGenerationTask }) {
|
||||
{taskStatusLabel(task.status)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 line-clamp-3 text-xs font-medium leading-5 text-muted-foreground">
|
||||
<p className="mt-2 line-clamp-3 text-xs leading-5 text-muted-foreground">
|
||||
{task.briefSummary}
|
||||
</p>
|
||||
{task.failureCode ? (
|
||||
@@ -382,7 +517,7 @@ function QuoteCard({
|
||||
{quote.quotedDesignPoints} 设计点
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs font-medium leading-5 text-muted-foreground">
|
||||
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||
{quote.briefSummary}
|
||||
</p>
|
||||
<Button
|
||||
@@ -405,22 +540,19 @@ export function ImageCanvas() {
|
||||
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
|
||||
const workspace = useImageWorkspaceStore((state) => state.workspace);
|
||||
const conversation = useImageWorkspaceStore((state) => state.conversation);
|
||||
const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId);
|
||||
const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation);
|
||||
const tasks = useImageWorkspaceStore((state) => state.tasks);
|
||||
const pendingTurn = useImageWorkspaceStore((state) => state.pendingTurn);
|
||||
const workspaceError = useImageWorkspaceStore((state) => state.error);
|
||||
const load = useImageWorkspaceStore((state) => state.load);
|
||||
const refreshWorkspace = useImageWorkspaceStore((state) => state.refreshWorkspace);
|
||||
const refreshConversation = useImageWorkspaceStore((state) => state.refreshConversation);
|
||||
const refreshTasks = useImageWorkspaceStore((state) => state.refreshTasks);
|
||||
const connectTaskStream = useImageWorkspaceStore((state) => state.connectTaskStream);
|
||||
const disconnectTaskStream = useImageWorkspaceStore((state) => state.disconnectTaskStream);
|
||||
const sendMessage = useImageWorkspaceStore((state) => state.sendMessage);
|
||||
const confirmGeneration = useImageWorkspaceStore((state) => state.confirmGeneration);
|
||||
const createConversation = useImageWorkspaceStore((state) => state.createConversation);
|
||||
const selectConversation = useImageWorkspaceStore((state) => state.selectConversation);
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [referenceAssets, setReferenceAssets] = useState<UploadedReferenceAsset[]>([]);
|
||||
const [referenceUploadBusy, setReferenceUploadBusy] = useState(false);
|
||||
const [referenceMentionTrigger, setReferenceMentionTrigger] = useState<ReferenceMentionTrigger | null>(null);
|
||||
const [activeMentionIndex, setActiveMentionIndex] = useState(0);
|
||||
const [submittingConversationId, setSubmittingConversationId] = useState<string | null>(null);
|
||||
const [confirmingQuoteId, setConfirmingQuoteId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
@@ -429,6 +561,9 @@ export function ImageCanvas() {
|
||||
const [imageSourceError, setImageSourceError] = useState<string | null>(null);
|
||||
const [uploadedImageSourceAsset, setUploadedImageSourceAsset] = useState<DesignAsset | null>(null);
|
||||
const conversationEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const promptRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const referenceUploadInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const promptSelectionRef = useRef({ start: 0, end: 0 });
|
||||
|
||||
const quote = useMemo(
|
||||
() => conversation ? activeQuote(conversation.messages) : null,
|
||||
@@ -449,6 +584,22 @@ export function ImageCanvas() {
|
||||
},
|
||||
[tasks, uploadedImageSourceAsset, workspace?.workspaceId],
|
||||
);
|
||||
const selectedReferenceAssetIds = useMemo(
|
||||
() => referenceAssets
|
||||
.filter(({ fileName }) => hasReferenceMention(prompt, referenceMentionToken(fileName)))
|
||||
.map(({ asset }) => asset.assetId),
|
||||
[prompt, referenceAssets],
|
||||
);
|
||||
const referenceMentionOptions = useMemo(() => {
|
||||
const query = referenceMentionTrigger?.query.trim().toLowerCase() ?? '';
|
||||
return referenceAssets.filter(({ asset, fileName }) => {
|
||||
if (selectedReferenceAssetIds.includes(asset.assetId)) return false;
|
||||
if (!query) return true;
|
||||
return fileName.toLowerCase().includes(query)
|
||||
|| referenceMentionToken(fileName).toLowerCase().includes(`@${query}`);
|
||||
});
|
||||
}, [referenceAssets, referenceMentionTrigger, selectedReferenceAssetIds]);
|
||||
const mentionMenuOpen = Boolean(referenceMentionTrigger && referenceMentionOptions.length > 0);
|
||||
const selectingImageReference = conversation?.brief.medium === 'image';
|
||||
const conversationMessages = useMemo<RenderedDesignMessage[]>(() => {
|
||||
if (!conversation) return [];
|
||||
@@ -498,6 +649,10 @@ export function ImageCanvas() {
|
||||
|
||||
useEffect(() => {
|
||||
setPrompt('');
|
||||
setReferenceAssets([]);
|
||||
setReferenceMentionTrigger(null);
|
||||
setActiveMentionIndex(0);
|
||||
promptSelectionRef.current = { start: 0, end: 0 };
|
||||
setActionError(null);
|
||||
setConfirmingQuoteId(null);
|
||||
setImageSourcePickerOpen(false);
|
||||
@@ -532,11 +687,102 @@ export function ImageCanvas() {
|
||||
}
|
||||
};
|
||||
|
||||
const updatePromptSelection = (target: HTMLTextAreaElement) => {
|
||||
const selection = {
|
||||
start: target.selectionStart,
|
||||
end: target.selectionEnd,
|
||||
};
|
||||
promptSelectionRef.current = selection;
|
||||
setReferenceMentionTrigger(
|
||||
getReferenceMentionTrigger(target.value, selection.start),
|
||||
);
|
||||
setActiveMentionIndex(0);
|
||||
};
|
||||
|
||||
const insertReferenceMention = (reference: UploadedReferenceAsset) => {
|
||||
const token = referenceMentionToken(reference.fileName);
|
||||
const trigger = referenceMentionTrigger;
|
||||
const selection = trigger ?? promptSelectionRef.current;
|
||||
const start = Math.max(0, Math.min(selection.start, prompt.length));
|
||||
const end = Math.max(start, Math.min(selection.end, prompt.length));
|
||||
const prefix = prompt.slice(0, start);
|
||||
const suffix = prompt.slice(end);
|
||||
const leadingSpace = prefix.length > 0 && !/\s$/u.test(prefix) ? ' ' : '';
|
||||
const insertion = `${leadingSpace}${token} `;
|
||||
const nextPrompt = `${prefix}${insertion}${suffix}`;
|
||||
const nextCursor = prefix.length + insertion.length;
|
||||
setPrompt(nextPrompt);
|
||||
setActionError(null);
|
||||
setReferenceMentionTrigger(null);
|
||||
setActiveMentionIndex(0);
|
||||
promptSelectionRef.current = { start: nextCursor, end: nextCursor };
|
||||
window.setTimeout(() => {
|
||||
promptRef.current?.focus();
|
||||
promptRef.current?.setSelectionRange(nextCursor, nextCursor);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const toggleReferenceAsset = (reference: UploadedReferenceAsset) => {
|
||||
const token = referenceMentionToken(reference.fileName);
|
||||
if (hasReferenceMention(prompt, token)) {
|
||||
const nextPrompt = removeReferenceMention(prompt, token);
|
||||
setPrompt(nextPrompt);
|
||||
setReferenceMentionTrigger(null);
|
||||
setActiveMentionIndex(0);
|
||||
const nextCursor = Math.min(promptSelectionRef.current.start, nextPrompt.length);
|
||||
promptSelectionRef.current = { start: nextCursor, end: nextCursor };
|
||||
promptRef.current?.focus();
|
||||
promptRef.current?.setSelectionRange(nextCursor, nextCursor);
|
||||
return;
|
||||
}
|
||||
insertReferenceMention(reference);
|
||||
};
|
||||
|
||||
const removeReferenceAsset = (reference: UploadedReferenceAsset) => {
|
||||
const token = referenceMentionToken(reference.fileName);
|
||||
setPrompt((current) => removeReferenceMention(current, token));
|
||||
setReferenceAssets((current) => current.filter(({ asset }) => asset.assetId !== reference.asset.assetId));
|
||||
setReferenceMentionTrigger(null);
|
||||
setActiveMentionIndex(0);
|
||||
};
|
||||
|
||||
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
event.currentTarget.value = '';
|
||||
if (!workspace || !conversation || !file || submitting || imageSourceBusyKey || referenceUploadBusy) return;
|
||||
const requestedWorkspaceId = workspace.workspaceId;
|
||||
const requestedConversationId = conversation.conversationId;
|
||||
setReferenceUploadBusy(true);
|
||||
setActionError(null);
|
||||
try {
|
||||
const asset = await uploadImageWorkspaceAsset(requestedWorkspaceId, file);
|
||||
const current = useImageWorkspaceStore.getState();
|
||||
if (current.activeWorkspaceId !== requestedWorkspaceId
|
||||
|| current.activeConversationId !== requestedConversationId) return;
|
||||
setReferenceAssets((references) => [
|
||||
...references.filter(({ asset: currentAsset }) => currentAsset.assetId !== asset.assetId),
|
||||
{ asset, fileName: file.name || '参考图' },
|
||||
]);
|
||||
toast.success('参考图已上传', {
|
||||
description: '点击缩略图或输入 @ 选择后带入需求',
|
||||
});
|
||||
} catch (error) {
|
||||
const current = useImageWorkspaceStore.getState();
|
||||
if (current.activeWorkspaceId === requestedWorkspaceId
|
||||
&& current.activeConversationId === requestedConversationId) {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
} finally {
|
||||
setReferenceUploadBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async (override?: string) => {
|
||||
const message = (override ?? prompt).trim();
|
||||
if (!workspace || !conversation || !message || submitting) return;
|
||||
if (isGenerationConfirmationIntent(message)) {
|
||||
setPrompt('');
|
||||
setReferenceMentionTrigger(null);
|
||||
if (quote && generationAvailable) {
|
||||
await handleConfirm(quote.quoteId);
|
||||
} else {
|
||||
@@ -549,8 +795,14 @@ export function ImageCanvas() {
|
||||
setSubmittingConversationId(requestedConversationId);
|
||||
setActionError(null);
|
||||
setPrompt('');
|
||||
setReferenceMentionTrigger(null);
|
||||
promptSelectionRef.current = { start: 0, end: 0 };
|
||||
try {
|
||||
await sendMessage(message);
|
||||
if (selectedReferenceAssetIds.length > 0) {
|
||||
await sendMessage(message, selectedReferenceAssetIds);
|
||||
} else {
|
||||
await sendMessage(message);
|
||||
}
|
||||
} catch (error) {
|
||||
const current = useImageWorkspaceStore.getState();
|
||||
if (current.activeWorkspaceId !== requestedWorkspaceId
|
||||
@@ -565,6 +817,36 @@ export function ImageCanvas() {
|
||||
};
|
||||
|
||||
const handleComposerKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (mentionMenuOpen && !event.nativeEvent.isComposing) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex((current) => (
|
||||
(current + 1) % referenceMentionOptions.length
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'ArrowUp') {
|
||||
event.preventDefault();
|
||||
setActiveMentionIndex((current) => (
|
||||
(current - 1 + referenceMentionOptions.length) % referenceMentionOptions.length
|
||||
));
|
||||
return;
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
const reference = referenceMentionOptions[activeMentionIndex];
|
||||
if (reference) {
|
||||
event.preventDefault();
|
||||
insertReferenceMention(reference);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
setReferenceMentionTrigger(null);
|
||||
setActiveMentionIndex(0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (event.key !== 'Enter' || event.shiftKey || event.nativeEvent.isComposing) return;
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
@@ -643,7 +925,7 @@ export function ImageCanvas() {
|
||||
|
||||
if (status === 'idle' || status === 'loading') {
|
||||
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">
|
||||
<div data-testid="image-canvas-page" className="flex h-full min-h-0 items-center justify-center bg-background p-6 font-sans text-foreground">
|
||||
<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>正在读取 AI 设计项目</span>
|
||||
@@ -654,16 +936,16 @@ export function ImageCanvas() {
|
||||
|
||||
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">
|
||||
<div data-testid="image-canvas-page" className="flex h-full min-h-0 items-center justify-center bg-background p-6 font-sans text-foreground">
|
||||
<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]">AI 设计暂不可用</h1>
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm font-medium leading-6 text-muted-foreground">
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm 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}
|
||||
{workspaceError ? <p role="alert" className="mt-3 rounded-xl bg-destructive/10 px-3 py-2 text-xs text-destructive">{workspaceError}</p> : null}
|
||||
<Button type="button" variant="outline" className="mt-5" onClick={() => void load()}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
重新连接
|
||||
@@ -675,13 +957,13 @@ export function ImageCanvas() {
|
||||
|
||||
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">
|
||||
<div data-testid="image-canvas-page" className="flex h-full min-h-0 items-center justify-center bg-background p-6 font-sans text-foreground">
|
||||
<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">
|
||||
<WandSparkles className="h-6 w-6" />
|
||||
</div>
|
||||
<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">
|
||||
<p className="mx-auto mt-3 max-w-sm text-sm leading-6 text-muted-foreground">
|
||||
每个项目会持续保留与设计 Agent 的对话、确认过的方向,以及图片和视频生成任务。
|
||||
</p>
|
||||
<Button type="button" className="mt-5 bg-brand text-primary-foreground" onClick={openCreateProject}>
|
||||
@@ -695,105 +977,30 @@ export function ImageCanvas() {
|
||||
|
||||
if (!workspace || !conversation) {
|
||||
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">
|
||||
<div data-testid="image-canvas-page" className="flex h-full min-h-0 items-center justify-center bg-background p-6 font-sans text-foreground">
|
||||
<Loader2 role="status" className="h-6 w-6 animate-spin text-brand" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-testid="image-canvas-page" className="-m-5 flex h-full min-h-0 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">
|
||||
<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]">{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">
|
||||
{conversation.title} · {conversation.brief.ready
|
||||
? conversation.brief.summary
|
||||
: '设计 Agent 正在与你确认创作方向'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<Select
|
||||
aria-label="切换设计会话"
|
||||
data-testid="image-canvas-conversation-select"
|
||||
className="h-10 w-36 max-w-[34vw] rounded-xl py-1.5 text-xs font-semibold sm:w-48"
|
||||
value={activeConversationId ?? ''}
|
||||
disabled={creatingConversation}
|
||||
onChange={(event) => {
|
||||
void selectConversation(event.currentTarget.value).catch((error) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{workspace.conversations.map((item) => (
|
||||
<option key={item.conversationId} value={item.conversationId}>
|
||||
{item.title}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="新建设计会话"
|
||||
aria-busy={creatingConversation}
|
||||
className="h-10 w-10 rounded-full transition-transform active:scale-[0.96]"
|
||||
disabled={creatingConversation}
|
||||
onClick={() => {
|
||||
void createConversation().catch((error) => {
|
||||
setActionError(error instanceof Error ? error.message : String(error));
|
||||
});
|
||||
}}
|
||||
>
|
||||
{creatingConversation
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <MessageSquarePlus className="h-4 w-4" />}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label="刷新设计项目"
|
||||
className="h-10 w-10 rounded-full transition-transform active:scale-[0.96]"
|
||||
onClick={() => {
|
||||
void refreshWorkspace()
|
||||
.then(() => refreshConversation())
|
||||
.then(() => refreshTasks())
|
||||
.catch(() => undefined);
|
||||
}}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:h-full">
|
||||
<div data-testid="image-canvas-page" className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden bg-background font-sans text-foreground">
|
||||
<div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:flex-row">
|
||||
<section data-testid="image-workspace-conversation-pane" className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden lg:h-full">
|
||||
<main data-testid="image-workspace-conversation" className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-5 sm:px-6">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-3xl flex-col pb-4">
|
||||
<div className="mx-auto flex min-h-full w-full flex-col pb-4">
|
||||
{conversationMessages.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
|
||||
data-testid="image-workspace-empty-state"
|
||||
className="chat-empty-state flex flex-1 items-center justify-center px-1 py-10 text-center sm:px-4"
|
||||
>
|
||||
<div className="w-full max-w-2xl">
|
||||
<div className="mx-auto flex h-12 w-14 items-center justify-center">
|
||||
<img src={logoSvg} alt="Makelore" className="h-11 w-14 object-contain" />
|
||||
</div>
|
||||
<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>
|
||||
<h2 className="mt-5 text-2xl font-medium tracking-[-0.025em] text-foreground sm:text-[28px]">
|
||||
今天一起描绘什么?
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -801,18 +1008,15 @@ export function ImageCanvas() {
|
||||
{conversationMessages.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>
|
||||
<article
|
||||
key={message.id}
|
||||
data-testid="image-workspace-message"
|
||||
data-message-role={message.role}
|
||||
className={cn('flex w-full', assistant ? 'justify-start' : 'justify-end')}
|
||||
>
|
||||
<div className={cn('min-w-0', assistant ? 'w-full' : 'w-fit max-w-[85%]')}>
|
||||
<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">
|
||||
<p className="chat-message-body whitespace-pre-wrap break-words">
|
||||
{message.text}
|
||||
{message.streaming && message.text ? (
|
||||
<span
|
||||
@@ -838,7 +1042,7 @@ export function ImageCanvas() {
|
||||
onConfirm={() => void handleConfirm(message.generationQuote!.quoteId)}
|
||||
/>
|
||||
) : null}
|
||||
{assistant && message.quickReplies.length > 0 ? (
|
||||
{shouldShowQuickReplies(message) ? (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{message.quickReplies.map((reply) => (
|
||||
<Button
|
||||
@@ -881,39 +1085,152 @@ export function ImageCanvas() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div
|
||||
data-testid="image-workspace-conversation-fade"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 top-0 z-10 h-14 bg-gradient-to-b from-background via-background/70 to-transparent"
|
||||
/>
|
||||
|
||||
<form
|
||||
data-testid="image-workspace-composer"
|
||||
className="shrink-0 bg-background px-4 pb-4 pt-2 sm:px-6"
|
||||
className="relative z-20 mx-auto mt-auto w-[calc(100%-2rem)] max-w-[46rem] shrink-0 pb-4 pt-2 sm:pb-5 sm:pt-3"
|
||||
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">
|
||||
{!generationAvailable ? (
|
||||
<p className="mb-2 rounded-xl bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
当前环境仅支持设计沟通,图片/视频生成能力尚未启用。
|
||||
</p>
|
||||
) : null}
|
||||
<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"
|
||||
/>
|
||||
{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">Enter 发送 · Shift+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
|
||||
<div
|
||||
data-testid="image-workspace-composer-surface"
|
||||
className="chat-composer-surface relative flex min-w-0 flex-col gap-2 rounded-lg border border-foreground/15 bg-background p-3 text-foreground shadow-soft transition-[background-color,border-color,box-shadow] focus-within:ring-1 focus-within:ring-foreground/10"
|
||||
>
|
||||
{!generationAvailable ? (
|
||||
<p className="mb-0 rounded-xl bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-700 dark:text-amber-300">
|
||||
当前环境仅支持设计沟通,图片/视频生成能力尚未启用。
|
||||
</p>
|
||||
) : null}
|
||||
{referenceAssets.length > 0 ? (
|
||||
<section
|
||||
data-testid="image-workspace-reference-candidates"
|
||||
className="-mx-1 flex min-w-0 flex-col gap-2 rounded-xl bg-surface-subtle/70 px-2 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] font-semibold text-muted-foreground">已上传参考图</span>
|
||||
<span className="text-[10px] text-muted-foreground">点击加入需求,或输入 @ 选择</span>
|
||||
</div>
|
||||
<div className="flex min-w-0 gap-2 overflow-x-auto pb-0.5">
|
||||
{referenceAssets.map((reference) => (
|
||||
<ReferenceAssetCandidate
|
||||
key={reference.asset.assetId}
|
||||
reference={reference}
|
||||
selected={selectedReferenceAssetIds.includes(reference.asset.assetId)}
|
||||
onToggle={() => toggleReferenceAsset(reference)}
|
||||
onRemove={() => removeReferenceAsset(reference)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
{mentionMenuOpen ? (
|
||||
<div
|
||||
data-testid="image-workspace-reference-mention-menu"
|
||||
role="listbox"
|
||||
aria-label="选择参考图"
|
||||
className="max-h-52 overflow-y-auto rounded-xl border border-border/70 bg-background p-1.5 shadow-float"
|
||||
>
|
||||
<p className="px-2 py-1 text-[10px] font-semibold text-muted-foreground">选择参考图</p>
|
||||
{referenceMentionOptions.map((reference, index) => (
|
||||
<button
|
||||
key={reference.asset.assetId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={index === activeMentionIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-lg px-2 py-2 text-left text-xs font-semibold transition-colors hover:bg-surface-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35',
|
||||
index === activeMentionIndex && 'bg-surface-subtle',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => insertReferenceMention(reference)}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-brand-soft text-brand">
|
||||
<ImageIcon className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{reference.fileName}</span>
|
||||
<span className="max-w-32 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{referenceMentionToken(reference.fileName)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
<Textarea
|
||||
ref={promptRef}
|
||||
aria-label="设计需求"
|
||||
value={prompt}
|
||||
onChange={(event) => {
|
||||
setPrompt(event.target.value);
|
||||
setActionError(null);
|
||||
updatePromptSelection(event.currentTarget);
|
||||
}}
|
||||
onSelect={(event) => updatePromptSelection(event.currentTarget)}
|
||||
onClick={(event) => updatePromptSelection(event.currentTarget)}
|
||||
onKeyDown={handleComposerKeyDown}
|
||||
rows={1}
|
||||
placeholder="告诉设计 Agent:要做什么、给谁看、希望是什么感觉…"
|
||||
className="max-h-40 !min-h-[42px] flex-1 resize-none border-0 bg-transparent px-0 py-2 text-sm leading-5 text-foreground shadow-none placeholder:text-muted-foreground focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
{actionError ? <p role="alert" className="text-xs font-medium text-destructive">{actionError}</p> : null}
|
||||
<div
|
||||
data-testid="image-workspace-composer-action-bar"
|
||||
className="flex items-center justify-between gap-1.5"
|
||||
>
|
||||
<div data-testid="image-workspace-composer-left-actions" className="flex items-center gap-1">
|
||||
<input
|
||||
id="image-reference-upload"
|
||||
data-testid="image-reference-upload-input"
|
||||
ref={referenceUploadInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp"
|
||||
aria-label="选择参考图文件"
|
||||
className="sr-only"
|
||||
disabled={submitting || Boolean(imageSourceBusyKey) || referenceUploadBusy}
|
||||
onChange={(event) => void handleReferenceUpload(event)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="motion-press h-8 rounded-full border-0 bg-transparent px-2 text-muted-foreground shadow-none hover:bg-surface-subtle hover:text-foreground active:scale-[0.96]"
|
||||
aria-label="上传参考图"
|
||||
aria-busy={referenceUploadBusy}
|
||||
disabled={submitting || Boolean(imageSourceBusyKey) || referenceUploadBusy}
|
||||
onClick={() => referenceUploadInputRef.current?.click()}
|
||||
>
|
||||
{referenceUploadBusy
|
||||
? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||
: <Plus className="mr-1.5 h-5 w-5" />}
|
||||
{referenceUploadBusy ? '上传中…' : '上传参考图'}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex min-w-0 items-center gap-0.5">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled
|
||||
className="motion-press h-8 w-8 rounded-full border-0 bg-transparent p-0 text-muted-foreground shadow-none"
|
||||
aria-label="语音输入"
|
||||
title="AI 绘画暂不支持语音输入"
|
||||
>
|
||||
<Mic className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="ghost"
|
||||
className="motion-press h-8 w-8 rounded-full border-0 bg-foreground p-0 text-background shadow-sm hover:bg-foreground/90 active:scale-[0.94]"
|
||||
aria-label="发送给设计 Agent"
|
||||
disabled={submitting || !prompt.trim()}
|
||||
>
|
||||
{submitting
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Send className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -921,27 +1238,29 @@ export function ImageCanvas() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div data-testid="design-pane-divider" aria-hidden="true" className="hidden w-px shrink-0 bg-border/70 lg:block" />
|
||||
|
||||
<aside data-testid="design-task-list" className="min-h-0 w-full shrink-0 overflow-hidden border-t border-border/70 bg-surface-subtle/45 lg:h-full lg:w-80 lg:border-t-0">
|
||||
<div className="flex items-center justify-between border-b border-border/70 px-4 py-3">
|
||||
<aside data-testid="design-task-list" className="relative min-h-0 w-full shrink-0 overflow-hidden border-t border-border/70 bg-surface-subtle/45 lg:flex lg:h-full lg:w-80 lg:flex-col lg:border-l lg:border-t-0 lg:border-border/70">
|
||||
<div className="flex shrink-0 items-center justify-between 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>
|
||||
<h2 className="text-sm font-semibold">生成内容</h2>
|
||||
</div>
|
||||
{hasActiveTasks ? <Clock3 className="h-4 w-4 animate-pulse text-brand" /> : null}
|
||||
</div>
|
||||
<div data-testid="design-task-scroll" className="h-full max-h-72 space-y-3 overflow-y-auto overscroll-contain p-3 lg:h-[calc(100%-57px)] lg:max-h-none">
|
||||
<div data-testid="design-task-scroll" className="min-h-0 max-h-72 space-y-3 overflow-y-auto overscroll-contain p-3 pb-8 lg:flex-1 lg:max-h-none">
|
||||
{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">
|
||||
<p className="mt-1 text-[11px] leading-5 text-muted-foreground">
|
||||
与 Agent 确认设计方向后,任务会出现在这里。
|
||||
</p>
|
||||
</div>
|
||||
) : tasks.map((task) => <TaskCard key={task.taskId} task={task} />)}
|
||||
</div>
|
||||
<div
|
||||
data-testid="design-task-fade"
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-14 bg-gradient-to-t from-surface-subtle via-surface-subtle/70 to-transparent"
|
||||
/>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user