feat(create): 历史对话 - 支持单条安全删除 (#237)
This commit is contained in:
@@ -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>}
|
||||
|
||||
@@ -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(",")}`] : [])))
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user