feat: 统一 AI 设计 Workspace 与生成任务链路

需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。

实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
2026-07-31 13:56:55 +08:00
parent 80e8386fa6
commit 3d9dd14918
22 changed files with 2506 additions and 1707 deletions

View File

@@ -1,35 +1,49 @@
import { useEffect, useState } from 'react';
import { Bot, FolderKanban, Loader2, Plus, RefreshCw, X } from 'lucide-react';
import {
FolderKanban,
Loader2,
Pencil,
Plus,
RefreshCw,
X,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { IMAGE_WORKSPACE_CREATE_PROJECT_EVENT } from '@/lib/image-workspace';
import { cn } from '@/lib/utils';
import { useImageWorkspaceStore } from '@/stores/image-workspace';
import type { DesignWorkspaceSummary } from '../../../shared/image-workspace';
type ImageWorkspaceSidebarProps = {
sidebarCollapsed: boolean;
};
type ProjectDialogState =
| { mode: 'create'; project: null }
| { mode: 'rename'; project: DesignWorkspaceSummary };
export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSidebarProps) {
const navigate = useNavigate();
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 activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const workspaceError = useImageWorkspaceStore((state) => state.error);
const load = useImageWorkspaceStore((state) => state.load);
const createProject = useImageWorkspaceStore((state) => state.createProject);
const addAgent = useImageWorkspaceStore((state) => state.addAgent);
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const selectAgent = useImageWorkspaceStore((state) => state.selectAgent);
const [projectsOpen, setProjectsOpen] = useState(true);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
const [projectName, setProjectName] = useState('');
const [createError, setCreateError] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [addingAgentProjectId, setAddingAgentProjectId] = useState<string | null>(null);
const [agentError, setAgentError] = useState<string | null>(null);
const [dialogError, setDialogError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (status === 'idle') void load();
@@ -38,8 +52,8 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
useEffect(() => {
const openCreateDialog = () => {
setProjectName('');
setCreateError(null);
setCreateDialogOpen(true);
setDialogError(null);
setDialog({ mode: 'create', project: null });
};
window.addEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
return () => window.removeEventListener(IMAGE_WORKSPACE_CREATE_PROJECT_EVENT, openCreateDialog);
@@ -47,49 +61,47 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
const openCreateDialog = () => {
setProjectName('');
setCreateError(null);
setCreateDialogOpen(true);
setDialogError(null);
setDialog({ mode: 'create', project: null });
};
const closeCreateDialog = () => {
if (creating) return;
setCreateDialogOpen(false);
setCreateError(null);
const openRenameDialog = (project: DesignWorkspaceSummary) => {
setProjectName(project.title);
setDialogError(null);
setDialog({ mode: 'rename', project });
};
const handleCreateProject = async () => {
const name = projectName.trim();
if (!name) {
setCreateError('请输入项目名称');
const closeDialog = () => {
if (saving) return;
setDialog(null);
setDialogError(null);
};
const saveProject = async () => {
const title = projectName.trim();
if (!title) {
setDialogError('请输入项目名称');
return;
}
setCreating(true);
setCreateError(null);
setSaving(true);
setDialogError(null);
try {
await createProject(name);
setCreateDialogOpen(false);
if (dialog?.mode === 'rename') {
await renameProject(dialog.project.workspaceId, title);
} else {
await createProject(title);
}
setDialog(null);
navigate('/image-canvas');
} catch (error) {
setCreateError(error instanceof Error ? error.message : String(error));
setDialogError(error instanceof Error ? error.message : String(error));
} finally {
setCreating(false);
setSaving(false);
}
};
const handleAddAgent = async (projectId: string) => {
setAddingAgentProjectId(projectId);
setAgentError(null);
try {
await addAgent(projectId);
} catch (error) {
setAgentError(error instanceof Error ? error.message : String(error));
} finally {
setAddingAgentProjectId(null);
}
};
const handleSelectProject = (projectId: string) => {
selectProject(projectId);
const handleSelectProject = (workspaceId: string) => {
void selectProject(workspaceId);
navigate('/image-canvas');
};
@@ -103,12 +115,12 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
'motion-press flex min-h-9 w-full items-center gap-2 rounded-lg border-0 bg-transparent px-2 py-1.5 text-left text-sm font-medium text-foreground shadow-none transition-colors hover:bg-surface-subtle active:scale-[0.985]',
sidebarCollapsed && 'justify-center px-0',
)}
aria-label="新建项目"
aria-label="新建设计项目"
onClick={openCreateDialog}
disabled={status !== 'ready'}
>
<Plus className="h-4 w-4 shrink-0 text-brand" />
{!sidebarCollapsed && '新建项目'}
{!sidebarCollapsed && '新建设计项目'}
</Button>
<div className="mt-3">
@@ -125,7 +137,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
<FolderKanban className="h-4 w-4 shrink-0" />
{!sidebarCollapsed ? (
<>
<span className="min-w-0 flex-1 truncate">项目列表</span>
<span className="min-w-0 flex-1 truncate">设计项目</span>
<span className="text-xs">{projectsOpen ? '收起' : '展开'}</span>
</>
) : null}
@@ -137,7 +149,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
<div role="status" className="rounded-lg border border-dashed border-border/80 bg-background/60 px-3 py-3 text-xs font-medium text-muted-foreground">
<div className="flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin text-brand" />
正在读取创作空间
正在读取设计项目
</div>
</div>
) : null}
@@ -147,7 +159,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
data-testid="sidebar-image-workspace-unavailable"
className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-3 text-xs font-medium text-muted-foreground"
>
<p className="font-semibold text-foreground">创作空间暂不可用</p>
<p className="font-semibold text-foreground">AI 设计暂不可用</p>
{workspaceError ? <p className="mt-1 break-words">{workspaceError}</p> : null}
<button
type="button"
@@ -160,20 +172,19 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
</div>
) : null}
{status === 'ready' && snapshot?.projects.length === 0 ? (
{status === 'ready' && bootstrap?.workspaces.length === 0 ? (
<div className="rounded-lg border border-dashed border-border/80 bg-background/60 px-3 py-3 text-xs font-medium leading-5 text-muted-foreground">
<span className="font-semibold text-foreground">还没有绘画项目</span>
<span className="mt-1 block">新建项目后请手动添加 Agent。</span>
<span className="font-semibold text-foreground">还没有设计项目</span>
<span className="mt-1 block">创建后,设计 Agent 会持续保留对话与生成任务。</span>
</div>
) : null}
{status === 'ready' ? snapshot?.projects.map((project) => {
const active = project.id === activeProjectId;
const selectedAgentId = activeAgentIds[project.id];
{status === 'ready' ? bootstrap?.workspaces.map((project) => {
const active = project.workspaceId === activeWorkspaceId;
return (
<section
key={project.id}
data-testid={`sidebar-image-project-${project.id}`}
key={project.workspaceId}
data-testid={`sidebar-image-project-${project.workspaceId}`}
className={cn(
'motion-press group/project relative overflow-hidden rounded-lg border px-2 py-1.5 text-left transition-[background-color,border-color,color]',
active
@@ -181,93 +192,67 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
: 'border-transparent bg-transparent hover:border-border/70 hover:bg-surface-subtle',
)}
>
<button
type="button"
aria-current={active ? 'page' : undefined}
className="flex w-full min-w-0 items-center gap-2 text-left"
onClick={() => handleSelectProject(project.id)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-input text-foreground">
<FolderKanban className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">{project.name}</span>
<span className="shrink-0 text-[10px] font-medium text-muted-foreground">{project.agents.length} 个 Agent</span>
</button>
<div className="mt-1.5 space-y-0.5 pl-9" aria-label={`${project.name} 的 Agent`}>
{project.agents.map((agent) => (
<button
key={agent.id}
type="button"
aria-pressed={active && selectedAgentId === agent.id}
aria-current={active && selectedAgentId === agent.id ? 'page' : undefined}
onClick={() => {
selectProject(project.id);
selectAgent(project.id, agent.id);
navigate('/image-canvas');
}}
className={cn(
'motion-press flex w-full items-center gap-2 rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-xs font-medium transition-colors hover:bg-background',
active && selectedAgentId === agent.id ? 'bg-background font-semibold shadow-soft' : '',
)}
>
<span className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-border bg-surface-tertiary">
{agent.avatarUrl ? (
<img src={agent.avatarUrl} alt="" className="h-full w-full object-cover" />
) : (
<Bot className="h-3.5 w-3.5 text-muted-foreground" />
)}
</span>
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
</button>
))}
<div className="flex min-w-0 items-center gap-1">
<button
type="button"
aria-label={`为 ${project.name} 添加 Agent`}
className="motion-press flex w-full items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-xs font-semibold text-brand hover:bg-background"
onClick={() => void handleAddAgent(project.id)}
disabled={addingAgentProjectId === project.id}
aria-current={active ? 'page' : undefined}
className="flex min-w-0 flex-1 items-center gap-2 text-left"
onClick={() => handleSelectProject(project.workspaceId)}
>
{addingAgentProjectId === project.id
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
添加 Agent
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md bg-surface-input text-foreground">
<FolderKanban className="h-3.5 w-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{project.title}</span>
<span className="block truncate text-[10px] font-medium text-muted-foreground">
{project.brief.summary}
</span>
</span>
</button>
<button
type="button"
aria-label={`重命名 ${project.title}`}
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-background hover:text-foreground group-hover/project:opacity-100 focus:opacity-100"
onClick={() => openRenameDialog(project)}
>
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
</section>
);
}) : null}
{agentError ? <p role="alert" className="rounded-md bg-destructive/10 p-2 text-xs font-semibold">{agentError}</p> : null}
</div>
) : null}
</div>
{createDialogOpen ? (
{dialog ? (
<Dialog
open={createDialogOpen}
open
onOpenChange={(open) => {
if (!open) closeCreateDialog();
if (!open) closeDialog();
}}
>
<DialogContent
className="max-w-sm p-5"
onInteractOutside={(event) => {
if (creating) event.preventDefault();
if (saving) event.preventDefault();
}}
>
<DialogHeader className="flex-row items-start justify-between gap-3 space-y-0">
<div>
<DialogTitle className="text-xl">新建项目</DialogTitle>
<DialogDescription className="mt-1 text-xs">填写名称后,再手动添加需要的 Agent。</DialogDescription>
<DialogTitle className="text-xl">
{dialog.mode === 'create' ? '新建设计项目' : '重命名设计项目'}
</DialogTitle>
<DialogDescription className="mt-1 text-xs">
对话、设计方向和生成任务都会归档在这个项目中。
</DialogDescription>
</div>
<button
type="button"
aria-label="关闭新建项目"
aria-label="关闭项目对话框"
className="flex h-8 w-8 items-center justify-center rounded-md border border-foreground/15 bg-white"
onClick={closeCreateDialog}
disabled={creating}
onClick={closeDialog}
disabled={saving}
>
<X className="h-4 w-4" />
</button>
@@ -277,38 +262,38 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
className="mt-4 space-y-3"
onSubmit={(event) => {
event.preventDefault();
void handleCreateProject();
void saveProject();
}}
>
<label className="block text-xs font-semibold" htmlFor="image-project-name">项目名称</label>
<input
id="image-project-name"
value={projectName}
maxLength={80}
onChange={(event) => {
setProjectName(event.target.value);
setCreateError(null);
setDialogError(null);
}}
autoFocus
disabled={creating}
disabled={saving}
className="w-full rounded-md border border-foreground/15 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-brand/20"
/>
{createError ? <p role="alert" className="rounded-md bg-destructive/10 p-2 text-xs font-semibold">{createError}</p> : null}
{dialogError ? <p role="alert" className="rounded-md bg-destructive/10 p-2 text-xs font-semibold">{dialogError}</p> : null}
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="outline" onClick={closeCreateDialog} disabled={creating}>取消</Button>
<Button type="button" variant="outline" onClick={closeDialog} disabled={saving}>取消</Button>
<Button
type="submit"
disabled={creating || !projectName.trim()}
disabled={saving || !projectName.trim()}
className="border border-foreground/15 bg-brand-soft font-semibold text-foreground"
>
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
创建项目
{saving ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{dialog.mode === 'create' ? '创建项目' : '保存名称'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
) : null}
</div>
);
}