feat: 支持 AI 设计项目多会话交互

需求:用户可在同一设计项目中新建和切换会话,任务列表保持项目级共享。

实现:
- 增加会话选择、新建入口及本地数据迁移
- Conversation 独立消息、Brief、Quote 与流式状态
- 保留项目任务并修复 Task revision 与 ABA 异步竞态
- 保持 Enter 发送、Shift+Enter 换行及 IME 保护
This commit is contained in:
2026-08-06 18:22:15 +08:00
parent 4980894017
commit 03dae62cf3
18 changed files with 1810 additions and 702 deletions

View File

@@ -2,12 +2,14 @@ import { useEffect, useState } from 'react';
import {
FolderKanban,
Loader2,
MessageSquareText,
Pencil,
Plus,
RefreshCw,
X,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Dialog,
@@ -36,11 +38,16 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
const status = useImageWorkspaceStore((state) => state.status);
const bootstrap = useImageWorkspaceStore((state) => state.bootstrap);
const activeWorkspaceId = useImageWorkspaceStore((state) => state.activeWorkspaceId);
const activeConversationId = useImageWorkspaceStore((state) => state.activeConversationId);
const workspace = useImageWorkspaceStore((state) => state.workspace);
const creatingConversation = useImageWorkspaceStore((state) => state.creatingConversation);
const workspaceError = useImageWorkspaceStore((state) => state.error);
const load = useImageWorkspaceStore((state) => state.load);
const createProject = useImageWorkspaceStore((state) => state.createProject);
const createConversation = useImageWorkspaceStore((state) => state.createConversation);
const renameProject = useImageWorkspaceStore((state) => state.renameProject);
const selectProject = useImageWorkspaceStore((state) => state.selectProject);
const selectConversation = useImageWorkspaceStore((state) => state.selectConversation);
const [projectsOpen, setProjectsOpen] = useState(true);
const [dialog, setDialog] = useState<ProjectDialogState | null>(null);
const [projectName, setProjectName] = useState('');
@@ -107,6 +114,15 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
navigate('/image-canvas');
};
const handleCreateConversation = async () => {
try {
await createConversation();
navigate('/image-canvas');
} catch (error) {
toast.error(error instanceof Error ? error.message : String(error));
}
};
return (
<div data-testid="sidebar-image-workspace" className="min-h-0 space-y-1">
<Button
@@ -207,7 +223,7 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
<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}
{project.conversationCount}
</span>
</span>
</button>
@@ -220,6 +236,58 @@ export function ImageWorkspaceSidebar({ sidebarCollapsed }: ImageWorkspaceSideba
<Pencil className="h-3.5 w-3.5" />
</button>
</div>
{active ? (
<div className="mt-1 border-t border-brand/15 pt-1.5">
<div className="flex min-h-10 items-center justify-between gap-2 px-1">
<span className="text-[10px] font-semibold text-muted-foreground"></span>
<button
type="button"
aria-label="新建设计会话"
title="新建设计会话"
aria-busy={creatingConversation}
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground transition-[background-color,color,transform] hover:bg-background hover:text-brand active:scale-[0.96]"
disabled={creatingConversation}
onClick={() => void handleCreateConversation()}
>
{creatingConversation
? <Loader2 className="h-3.5 w-3.5 animate-spin" />
: <Plus className="h-3.5 w-3.5" />}
</button>
</div>
<div className="space-y-0.5 pb-1">
{workspace?.workspaceId === project.workspaceId
? workspace.conversations.slice(0, 6).map((conversation) => {
const selected = conversation.conversationId === activeConversationId;
return (
<button
key={conversation.conversationId}
type="button"
data-testid={`sidebar-image-conversation-${conversation.conversationId}`}
aria-current={selected ? 'page' : undefined}
className={cn(
'flex min-h-10 w-full items-center gap-2 rounded-lg px-2 text-left transition-[background-color,color,transform] active:scale-[0.96]',
selected
? 'bg-background text-foreground shadow-soft'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
)}
onClick={() => void selectConversation(conversation.conversationId)}
>
<MessageSquareText className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1">
<span className="block truncate text-[11px] font-semibold">
{conversation.title}
</span>
<span className="block truncate text-[10px]">
{conversation.brief.summary}
</span>
</span>
</button>
);
})
: null}
</div>
</div>
) : null}
</section>
);
}) : null}