feat: 统一 AI 设计 Workspace 与生成任务链路
需求:以设计项目组织固定设计 Agent 对话、方向确认和图片视频任务。 实现:新增 Works Square 云端适配与开发态本地适配,统一 Host API、Quote 确认、任务轮询及私有媒体 Range 代理。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user