fix: improve agent project UX and placeholder persistence
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-24 14:06:04 +08:00
parent 5b30c2616b
commit ae93dfcfda
3 changed files with 172 additions and 45 deletions

View File

@ -23,9 +23,11 @@ import {
Wand2,
Grid3X3,
Settings,
Pencil,
PenTool,
Box,
Layers,
FolderOpen,
Crosshair,
Scan,
} from "lucide-react";
@ -190,6 +192,17 @@ function buildDefaultAgentProjectTitle() {
return `生图项目 ${month}-${day} ${hour}:${minute}`;
}
function buildSaveAsAgentProjectTitle(currentTitle?: string | null) {
const normalized = String(currentTitle || "").trim();
if (!normalized) {
return `${buildDefaultAgentProjectTitle()} 副本`;
}
if (normalized.endsWith("副本")) {
return normalized;
}
return `${normalized} 副本`;
}
function areStringArraysEqual(left: string[], right: string[]) {
if (left === right) return true;
if (left.length !== right.length) return false;
@ -803,6 +816,7 @@ export default function CanvasEditorPage({ params }: PageProps) {
throw new Error(result.error || "创建生图项目失败");
}
if (cancelled) return;
toast.success("已为你创建新项目,可直接开始生图");
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
@ -919,15 +933,9 @@ export default function CanvasEditorPage({ params }: PageProps) {
workflowSelections,
]);
const handleManualSaveAgentProject = useCallback(async () => {
if (!agentProjectId) {
toast.error("当前还没有可保存的生图项目");
return;
}
const suggestedTitle =
String(agentProjectTitle || "").trim() || buildDefaultAgentProjectTitle();
const promptedTitle = window.prompt("请输入要保存的项目名称", suggestedTitle);
const handleSaveAsAgentProject = useCallback(async () => {
const suggestedTitle = buildSaveAsAgentProjectTitle(agentProjectTitle);
const promptedTitle = window.prompt("请输入另存为的新项目名称", suggestedTitle);
if (promptedTitle === null) {
return;
}
@ -939,16 +947,36 @@ export default function CanvasEditorPage({ params }: PageProps) {
setIsAgentProjectSaving(true);
try {
const success = await persistAgentProject("manual", { title: nextTitle });
if (success) {
toast.success("生图项目已保存");
} else {
toast.error("保存生图项目失败");
if (agentProjectId) {
await persistAgentProject("switch");
}
const result = await agentProjectService.createProject({
title: nextTitle,
thumbnail: agentProjectThumbnail,
projectState: buildPersistedAgentProjectState(),
});
if (!result.success || !result.data) {
toast.error(result.error || "另存为新项目失败");
return;
}
toast.success("已另存为新项目");
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
});
} finally {
setIsAgentProjectSaving(false);
}
}, [agentProjectId, persistAgentProject]);
}, [
agentProjectId,
agentProjectThumbnail,
agentProjectTitle,
buildPersistedAgentProjectState,
persistAgentProject,
updateCanvasSearchParams,
]);
const handleRenameAgentProject = useCallback(
async (targetProjectId: string, currentTitle?: string | null) => {
@ -980,6 +1008,15 @@ export default function CanvasEditorPage({ params }: PageProps) {
[agentProjectId]
);
const handleRenameCurrentAgentProject = useCallback(async () => {
if (!agentProjectId) {
toast.error("当前还没有可重命名的项目");
return;
}
await handleRenameAgentProject(agentProjectId, agentProjectTitle);
}, [agentProjectId, agentProjectTitle, handleRenameAgentProject]);
const handleCreateAgentProject = useCallback(async () => {
if (agentProjectId) {
await persistAgentProject("switch");
@ -987,20 +1024,20 @@ export default function CanvasEditorPage({ params }: PageProps) {
const result = await agentProjectService.createProject({
title: buildDefaultAgentProjectTitle(),
projectState: buildPersistedAgentProjectState(),
projectState: createEmptyAgentGenerationProjectPersistedState(),
});
if (!result.success || !result.data) {
toast.error(result.error || "创建生图项目失败");
return;
}
toast.success("已新建空白项目");
updateCanvasSearchParams({
mode: "agent",
agentProjectId: result.data.id,
});
}, [
agentProjectId,
buildPersistedAgentProjectState,
persistAgentProject,
updateCanvasSearchParams,
]);
@ -1607,6 +1644,17 @@ export default function CanvasEditorPage({ params }: PageProps) {
}),
[resolvedWorkflowUiConfig, workflowSelections]
);
const resolvedAgentProjectTitle =
String(agentProjectTitle || "").trim() || "未命名生图项目";
const agentProjectStatusLabel = isAgentProjectLoading
? hasActiveAgentProject
? "项目加载中..."
: "正在创建新项目..."
: isAgentProjectSaving
? "自动保存中..."
: hasActiveAgentProject
? "已自动保存"
: "准备创建项目...";
if (isLoading) {
return (
@ -1655,6 +1703,23 @@ export default function CanvasEditorPage({ params }: PageProps) {
const agentProjectNavSlot = isAgentMode ? (
<div className="flex items-center gap-2">
<div className="hidden min-w-0 max-w-[280px] flex-col rounded-[18px] border border-white/10 bg-white/[0.03] px-3 py-2 sm:flex">
<div className="text-[10px] font-medium uppercase tracking-[0.18em] text-white/35">
当前项目
</div>
<div className="truncate text-sm font-semibold text-white">
{resolvedAgentProjectTitle}
</div>
<div className="mt-1 flex items-center gap-1.5 text-xs text-white/52">
{isAgentProjectLoading || isAgentProjectSaving ? (
<Loader2 className="h-3.5 w-3.5 animate-spin text-emerald-300" />
) : (
<span className="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]" />
)}
<span>{agentProjectStatusLabel}</span>
</div>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@ -1666,25 +1731,47 @@ export default function CanvasEditorPage({ params }: PageProps) {
{isAgentProjectSaving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Save className="h-4 w-4" />
<FolderOpen className="h-4 w-4" />
)}
项目管理
项目操作
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="w-44 border-white/10 bg-[#0b0d0f] text-white"
className="w-48 border-white/10 bg-[#0b0d0f] text-white"
>
<DropdownMenuItem
disabled={isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
void handleCreateAgentProject();
}}
>
<Plus className="mr-2 h-4 w-4" />
新建项目
</DropdownMenuItem>
<DropdownMenuItem
disabled={isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
void handleSaveAsAgentProject();
}}
>
<Save className="mr-2 h-4 w-4" />
另存为新项目
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasActiveAgentProject || isAgentProjectLoading || isAgentProjectSaving}
className="cursor-pointer text-white focus:bg-white/10 focus:text-white"
onSelect={(event) => {
event.preventDefault();
void handleManualSaveAgentProject();
void handleRenameCurrentAgentProject();
}}
>
<Save className="mr-2 h-4 w-4" />
保存项目
<Pencil className="mr-2 h-4 w-4" />
重命名当前项目
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-white/10" />
<DropdownMenuItem

View File

@ -275,6 +275,15 @@ function normalizeAssistantText(value: string) {
.trim();
}
function hasLikelyGenerationErrorText(value: string | null | undefined) {
const normalized = normalizeAssistantText(String(value || ""));
if (!normalized) {
return false;
}
return /失败|超时|重试|error|failed/i.test(normalized);
}
function getImageDimensions(url: string): Promise<{
width: number;
height: number;
@ -934,9 +943,6 @@ export function AgentGenerationWorkspaceDarkV2({
messages: historyMessages,
experimental_throttle: 100,
generateId: generateUUID,
onError: () => {
setPendingGeneration(null);
},
transport: new DefaultChatTransport({
api: "/api/chat",
fetch: fetchWithErrorHandlers,
@ -1171,21 +1177,39 @@ export function AgentGenerationWorkspaceDarkV2({
() => messages.filter((message) => message.role === "user").length,
[messages]
);
const generatedGroupsAfterLastUser = useMemo(() => {
const assistantMessagesAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex < 0) {
return [] as GeneratedImageGroup[];
return [] as ChatMessage[];
}
return messages
.slice(lastUserIndex + 1)
.filter((message) => message.role === "assistant")
.filter((message) => message.role === "assistant");
}, [messages]);
const hasAssistantFailureAfterLastUser = useMemo(
() =>
assistantMessagesAfterLastUser.some((message) => {
const text = (message.parts || [])
.filter((part: any) => part.type === "text" && part.text)
.map((part: any) => String(part.text))
.join("\n\n");
if (!hasLikelyGenerationErrorText(text)) {
return false;
}
const imageUrls = extractImageUrlsFromText(text);
return imageUrls.length === 0;
}),
[assistantMessagesAfterLastUser]
);
const generatedGroupsAfterLastUser = useMemo(() => {
return assistantMessagesAfterLastUser
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group));
}, [generatedGroups, messages]);
}, [assistantMessagesAfterLastUser, generatedGroups]);
const allGeneratedImages = useMemo(
() => Array.from(new Set(generatedGroups.flatMap((group) => group.imageUrls))),
@ -1196,19 +1220,10 @@ export function AgentGenerationWorkspaceDarkV2({
[generatedGroupsAfterLastUser]
);
const replacementGeneratedGroupsAfterLastUser = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
.lastIndexOf("user");
if (lastUserIndex < 0) {
return [] as GeneratedImageGroup[];
}
return messages
.slice(lastUserIndex + 1)
.filter((message) => message.role === "assistant")
return assistantMessagesAfterLastUser
.map((message) => generatedGroups.find((group) => group.messageId === message.id) || null)
.filter((group): group is GeneratedImageGroup => Boolean(group));
}, [generatedGroups, messages]);
}, [assistantMessagesAfterLastUser, generatedGroups]);
const latestGeneratedGroup = useMemo(() => {
const lastUserIndex = [...messages]
.map((message) => message.role)
@ -1593,6 +1608,31 @@ export function AgentGenerationWorkspaceDarkV2({
}
}, [pendingGeneration, pendingGenerationProgressCount]);
useEffect(() => {
if (!pendingGeneration || transportIsGenerating) {
return;
}
if (!hasAssistantFailureAfterLastUser) {
return;
}
const hasProgress =
pendingGeneration.modeType === "scene_generation"
? currentSceneGeneratedUrls.length > 0
: latestReplacementResults.length > 0;
if (!hasProgress) {
setPendingGeneration(null);
}
}, [
currentSceneGeneratedUrls.length,
hasAssistantFailureAfterLastUser,
latestReplacementResults.length,
pendingGeneration,
transportIsGenerating,
]);
useEffect(() => {
onResultPreviewUrlsChange?.(
currentResultModeType,

View File

@ -72,7 +72,7 @@ export function AgentProjectListSheet({
<SheetHeader className="border-b border-white/10 px-5 py-4 text-left">
<SheetTitle className="text-white">生图项目列表</SheetTitle>
<SheetDescription className="text-white/45">
这里保存独立于无限画布的生图项目,可随时继续生成。
当前工作台会自动保存到项目中,这里可以随时切换、重命名或继续生成。
</SheetDescription>
</SheetHeader>
@ -87,7 +87,7 @@ export function AgentProjectListSheet({
className="h-10 w-full gap-2 bg-emerald-500 text-white hover:bg-emerald-400"
>
<Plus className="h-4 w-4" />
新建生图项目
新建空白项目
</Button>
</div>
@ -201,7 +201,7 @@ export function AgentProjectListSheet({
) : (
<div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] px-4 py-12 text-center">
<div className="text-sm text-white/70">还没有生图项目</div>
<div className="mt-2 text-xs text-white/40">点击上方按钮创建一个独立项目。</div>
<div className="mt-2 text-xs text-white/40">点击上方按钮创建一个空白项目开始工作。</div>
</div>
)}
</div>