From 6cdece9e04cf1a74b2d8366ac7c3ca522ea08081 Mon Sep 17 00:00:00 2001 From: MasterChiefCN <49179444+MasterChiefCN@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:44:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(create):=20=E5=8E=86=E5=8F=B2=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=20-=20=E6=94=AF=E6=8C=81=E5=8D=95=E6=9D=A1=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E5=88=A0=E9=99=A4=20(#237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/pages/create/index.tsx | 56 +++++++++++++++++-- .../services/creation-conversation-store.ts | 8 +++ web/src/styles/globals.css | 14 +++-- web/test/create-canvas-handoff.test.ts | 13 ++++- 4 files changed, 79 insertions(+), 12 deletions(-) diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 16c6a8406..5b61ca8c7 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type CSSProperties, type ReactNode, type RefObject } from "react"; import { App, Button, Drawer, Modal, Popover, Spin, Tooltip } from "antd"; -import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, X } from "lucide-react"; +import { ArrowDown, ArrowUp, Check, ChevronDown, Clapperboard, Clock3, Copy, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Music2, Paperclip, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Trash2, X } from "lucide-react"; import { Link } from "react-router"; import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown"; @@ -28,7 +28,7 @@ import { uploadImage } from "@/services/image-storage"; import { consumeGenerationTaskMessage, generationTaskMaterializedUrls, materializeGenerationTaskAssets, projectGenerationTaskResult } from "@/services/project-asset-sync"; import { applyGenerationConsumerEffect } from "@/services/generation-consumer-dedupe"; import { beginGenerationConsumer, runGenerationConsumer } from "@/services/generation-consumer-lifecycle"; -import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; +import { loadCreationConversations, pendingCreationMediaKey, pendingCreationTaskIds, removeCreationConversationSnapshot, saveCreationConversations, updateCreationConversationSnapshot } from "@/services/creation-conversation-store"; import { modelDisplayName, modelOptionName, resolveModelChannel, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store"; import { useAssetStore, type Asset } from "@/stores/use-asset-store"; import { useUserStore } from "@/stores/use-user-store"; @@ -120,7 +120,7 @@ function completedCreationGenerationTask(input: { taskId: string; task?: Generat } export default function CreatePage() { - const { message: toast } = App.useApp(); + const { message: toast, modal } = App.useApp(); const config = useEffectiveConfig(); const updateConfig = useConfigStore((state) => state.updateConfig); const assets = useAssetStore((state) => state.assets); @@ -128,6 +128,7 @@ export default function CreatePage() { const [conversations, setConversations] = useState([]); const conversationsRef = useRef([]); const [activeId, setActiveId] = useState(""); + const activeIdRef = useRef(""); const [hydrated, setHydrated] = useState(false); const [mode, setMode] = useState("video"); const [prompt, setPrompt] = useState(""); @@ -225,6 +226,10 @@ export default function CreatePage() { useEffect(() => () => abortRef.current?.abort(), []); + useEffect(() => { + activeIdRef.current = activeId; + }, [activeId]); + useEffect(() => { conversationsRef.current = conversations; if (hydrated) void saveCreationConversations(conversations); @@ -599,6 +604,44 @@ export default function CreatePage() { setHistoryOpen(false); }; + const confirmDeleteConversation = (conversation: CreationConversation) => { + const title = conversation.title.trim() || "新创作"; + const label = title.length > 32 ? `${title.slice(0, 32)}...` : title; + modal.confirm({ + className: "workspace-modal workspace-modal-compact", + title: "删除历史对话?", + content: `确定删除「${label}」吗?这只会删除历史对话记录,不会删除已上传或生成的任何素材。此操作不可撤销。`, + okText: "删除对话", + okButtonProps: { danger: true }, + cancelText: "保留", + onOk: async () => { + try { + const remaining = removeCreationConversationSnapshot(conversationsRef.current, conversation.id); + const sortedRemaining = [...remaining].sort((left, right) => conversationTimestamp(right.updatedAt) - conversationTimestamp(left.updatedAt)); + const fallback = sortedRemaining.find((item) => item.messages.length > 0) || sortedRemaining[0] || newConversation(); + const next = remaining.length ? remaining : [fallback]; + await saveCreationConversations(next); + conversationsRef.current = next; + setConversations(next); + if (activeIdRef.current === conversation.id) { + followLatestMessageRef.current = true; + activeIdRef.current = fallback.id; + setActiveId(fallback.id); + setPrompt(""); + setAttachments([]); + setDraftReferences([]); + setSelectedShotIndex(-1); + setComposingNextShot(false); + } + toast.success("历史对话已删除,素材仍保留"); + } catch (error) { + toast.error(error instanceof Error ? error.message : "历史对话删除失败"); + throw error; + } + }, + }); + }; + const restoreMessageDraft = (item: CreationMessage) => { const nextMode = item.mode || "text"; const nextSettings = item.settings; @@ -793,7 +836,7 @@ export default function CreatePage() { } - setHistoryOpen(false)} onSelect={selectConversation} /> + setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} /> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" }; -function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) { +function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect, onDelete }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void }) { const [keyword, setKeyword] = useState(""); useEffect(() => { @@ -841,11 +884,12 @@ function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelec const latest = conversationPreviewMessage(conversation); const active = conversation.id === activeId; return
  • - +
  • ; })} :
    {keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}
    } diff --git a/web/src/services/creation-conversation-store.ts b/web/src/services/creation-conversation-store.ts index 4162e01b1..8295e3b4a 100644 --- a/web/src/services/creation-conversation-store.ts +++ b/web/src/services/creation-conversation-store.ts @@ -20,6 +20,14 @@ export function updateCreationConversationSnapshot(con return conversations.map((conversation) => (conversation.id === conversationId ? updater(conversation) : conversation)); } +// 对话、生成任务与素材是独立持久状态;删除历史记录不能在这里级联清理任务或资源。 +export function removeCreationConversationSnapshot(conversations: T[], conversationId: string) { + if (!conversationId) throw new Error("缺少要删除的创作对话 ID"); + const next = conversations.filter((conversation) => conversation.id !== conversationId); + if (next.length === conversations.length) throw new Error("要删除的创作对话不存在"); + return next; +} + export function pendingCreationMediaKey(conversations: StoredCreationConversation[]) { return conversations .flatMap((conversation) => conversation.messages.flatMap((message) => (message.role === "assistant" && message.status === "pending" && message.mode !== "text" ? [`${conversation.id}:${message.id}:${(message.taskIds || []).join(",")}`] : []))) diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 72aec7a27..fed2644a3 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -7933,11 +7933,15 @@ html:not(.dark) .creation-history-drawer-root { .creation-history-search input::placeholder { color: var(--creation-history-muted); } .creation-history-list { display: grid; gap: var(--space-1); margin: 0; padding: 0; list-style: none; } -.creation-history-list > li { min-width: 0; } -.creation-history-list button { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 10px 13px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; } -.creation-history-list button:hover { background: var(--creation-history-hover); } -.creation-history-list button:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; } -.creation-history-list > li.is-active button { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); } +.creation-history-list > li { position: relative; min-width: 0; } +.creation-history-item-main { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 50px 13px 10px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; } +.creation-history-item-main:hover { background: var(--creation-history-hover); } +.creation-history-item-main:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; } +.creation-history-list > li.is-active .creation-history-item-main { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); } +.creation-history-delete { position: absolute; z-index: 1; top: 50%; right: 9px; display: grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--creation-history-muted); transform: translateY(-50%); transition: background-color 150ms ease, color 150ms ease; } +.creation-history-delete svg { width: 16px; height: 16px; } +.creation-history-delete:hover { background: color-mix(in srgb, var(--destructive) 12%, transparent); color: var(--destructive); } +.creation-history-delete:focus-visible { outline: 2px solid color-mix(in srgb, var(--destructive) 60%, transparent); outline-offset: 1px; } .creation-history-time { display: flex; width: 100%; align-items: center; justify-content: space-between; color: var(--creation-history-muted); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; line-height: 1.3; } .creation-history-time em { border: 1px solid var(--creation-history-line); border-radius: var(--r-xs); padding: 2px 5px; color: var(--creation-history-muted); font-size: var(--fs-micro); font-style: normal; } .creation-history-item-heading { width: 100%; min-width: 0; overflow: hidden; font-size: var(--fs-label); font-weight: 650; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } diff --git a/web/test/create-canvas-handoff.test.ts b/web/test/create-canvas-handoff.test.ts index 9d20ab33c..4529c6a57 100644 --- a/web/test/create-canvas-handoff.test.ts +++ b/web/test/create-canvas-handoff.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import { createGenerationTaskSubscriptionService, type GenerationTask } from "../src/services/api/task-center"; -import { updateCreationConversationSnapshot } from "../src/services/creation-conversation-store"; +import { removeCreationConversationSnapshot, updateCreationConversationSnapshot } from "../src/services/creation-conversation-store"; test("Create exposes one accessible copy action beside each displayed user prompt", async () => { const source = await Bun.file(new URL("../src/pages/create/index.tsx", import.meta.url)).text(); @@ -35,6 +35,17 @@ test("Create keeps optimistic messages when the first local task binds immediate expect(bound[0].messages[1]).toMatchObject({ id: "assistant-0001", taskIds: ["local:dreamina-cli:task-0001"] }); }); +test("Create history deletion removes only the selected conversation snapshot", () => { + const assets = [{ id: "asset-0001", storageKey: "resource:asset-0001" }]; + const conversations = [ + { id: "conversation-0001", messages: [{ id: "message-0001", resultUrls: ["resource:asset-0001"] }] }, + { id: "conversation-0002", messages: [{ id: "message-0002", attachments: [{ storageKey: "resource:asset-0001" }] }] }, + ]; + + expect(removeCreationConversationSnapshot(conversations, "conversation-0001")).toEqual([conversations[1]]); + expect(assets).toEqual([{ id: "asset-0001", storageKey: "resource:asset-0001" }]); +}); + test("Create refresh subscriptions share one durable scheduler observation without page polling", async () => { let queryCalls = 0; let waitCalls = 0;