fix: improve agent project UX and placeholder persistence
This commit is contained in:
parent
5b30c2616b
commit
ae93dfcfda
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user