feat(create): 历史对话 - 支持单条安全删除 (#237)

This commit is contained in:
MasterChiefCN
2026-08-16 14:44:53 +08:00
committed by GitHub
parent 852c151b8a
commit 6cdece9e04
4 changed files with 79 additions and 12 deletions

View File

@@ -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<CreationConversation[]>([]);
const conversationsRef = useRef<CreationConversation[]>([]);
const [activeId, setActiveId] = useState("");
const activeIdRef = useRef("");
const [hydrated, setHydrated] = useState(false);
const [mode, setMode] = useState<CreationMode>("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() {
</section>
</div>}
</div>
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} onDelete={confirmDeleteConversation} />
<AssetLibraryPickerModal
open={libraryOpen}
items={libraryItems}
@@ -808,7 +851,7 @@ export default function CreatePage() {
const creationAssetCategoryLabels: Record<string, string> = { 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 <li key={conversation.id} className={active ? "is-active" : undefined}>
<button type="button" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
<button type="button" className="creation-history-item-main" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
<span className="creation-history-time"><time dateTime={conversation.updatedAt}>{formatConversationTime(conversation.updatedAt)}</time><em>{latest?.mode ? modeLabels[latest.mode] : "创作"}</em></span>
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
<span className="creation-history-snippet">{latest ? displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作" : "还没有开始创作"}</span>
</button>
<Tooltip title="删除对话"><button type="button" className="creation-history-delete" aria-label={`删除对话:${conversation.title.trim() || "新创作"}`} onClick={() => onDelete(conversation)}><Trash2 /></button></Tooltip>
</li>;
})}
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}

View File

@@ -20,6 +20,14 @@ export function updateCreationConversationSnapshot<T extends { id: string }>(con
return conversations.map((conversation) => (conversation.id === conversationId ? updater(conversation) : conversation));
}
// 对话、生成任务与素材是独立持久状态;删除历史记录不能在这里级联清理任务或资源。
export function removeCreationConversationSnapshot<T extends { id: string }>(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(",")}`] : [])))

View File

@@ -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; }

View File

@@ -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;