refactor(*): 平台架构 - 拆分后端领域模块与画布前端组件并补充测试

This commit is contained in:
ddcat666
2026-09-30 10:01:39 +08:00
parent e0a2697c60
commit 7f5d87ef1a
186 changed files with 33727 additions and 29905 deletions

View File

@@ -0,0 +1,97 @@
// 创作页空态:横幅、推荐提示词与精选作品。
import { useAppearanceStore } from "@/stores/use-appearance-store";
import type { CreationMode } from "./creation-assets";
import { ArrowUp, ChevronDown, Clapperboard, FileText, Image as ImageIcon, Sparkles } from "lucide-react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useState } from "react";
import { aceternityMotion } from "@/lib/aceternity-motion";
import { creationFeaturedWorks, inspirationSource } from "./creation-inspirations";
import { modeLabels } from "./creation-types";
import { Button } from "antd";
export const creationEmptyBannerFrames = [
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "镜头01 · 雨夜霓虹" },
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "镜头02 · 暗巷追逐" },
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "镜头03 · 天台重逢" },
];
export function CreationEmptyBanner() {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
return <div className="creation-empty-art" aria-hidden="true">
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
<img src={frame.src} alt="" />
<span>{frame.caption}</span>
</figure>)}
<span className="creation-empty-art-caption"><span>{brandName}</span>把每一帧,交给镜头导演</span>
</div>;
}
export const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [
{ mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" },
{ mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true },
{ mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" },
{ mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" },
];
export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
const reducedMotion = useReducedMotion();
const [hovered, setHovered] = useState<string | null>(null);
return <LayoutGroup id="creation-empty-suggest">
<div className="creation-empty-suggest" aria-label="快捷创作入口">
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
const start = () => {
if (item.openLibrary) onOpenLibrary();
else onStartPrompt(item.mode, item.prompt);
};
return <motion.button
key={item.title}
type="button"
className="suggest-card"
onClick={start}
onHoverStart={() => setHovered(item.title)}
onHoverEnd={() => setHovered(null)}
whileHover={reducedMotion ? undefined : { y: -2 }}
transition={aceternityMotion.spring.surface}
>
{hovered === item.title ? (
<motion.span
layoutId="creation-suggest-hover"
className="suggest-card-hover"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.surface}
/>
) : null}
<span className="library-icon-tile suggest-icon"><Icon size={18} strokeWidth={2} /></span>
<span className="suggest-copy">
<strong>{item.title}</strong>
<span>{item.hint}</span>
</span>
</motion.button>;
})}
</div>
</LayoutGroup>;
}
export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
const [filter, setFilter] = useState<"all" | CreationMode>("all");
const [limit, setLimit] = useState(12);
const filtered = creationFeaturedWorks.filter((item) => filter === "all" || item.mode === filter);
return <section className="creation-featured-works" aria-labelledby="creation-featured-title">
<div className="creation-featured-heading">
<div><h2 id="creation-featured-title">精选灵感</h2></div>
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
</div>
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
{(["all", "video", "image", "text"] as const).map((value) => <button key={value} type="button" aria-pressed={filter === value} onClick={() => { setFilter(value); setLimit(12); }}>{value === "all" ? "全部灵感" : modeLabels[value]}<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span></button>)}
</div>
<div className="creation-featured-layout">
{filtered.slice(0, limit).map((item, index) => <button key={item.title} type="button" className={`product-collection-card creation-featured-card ${index === 0 ? "is-featured-hero" : ""}`} onClick={() => onStartPrompt(item.mode, item.prompt)}>
<span className="creation-featured-media"><img src={item.image} alt="" loading="lazy" /><span className="creation-inspiration-overlay"><ArrowUp />使用这个创意</span></span>
<span className="creation-featured-copy"><strong>{item.title}</strong><span>{item.description}</span><em><Sparkles />{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}</em></span>
</button>)}
</div>
<footer className="creation-inspiration-footer">{limit < filtered.length ? <Button onClick={() => setLimit((count) => count + 12)}>展开更多灵感<ChevronDown /></Button> : <span>已展示全部 {filtered.length} 个创意</span>}<details><summary>模板与封面来源</summary><p>{inspirationSource.notice}</p><a href={inspirationSource.repository} target="_blank" rel="noreferrer">awesome-chatgpt-prompts · CC0</a></details></footer>
</section>;
}

View File

@@ -0,0 +1,194 @@
// 创作页历史会话抽屉、导出与时间格式化。
import { conversationTimeFormatter, type CreationConversation, type CreationMessage, historyDayFormatter, modeLabels } from "./creation-types";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
import { App, Dropdown } from "antd";
import { displayCreationPrompt } from "./creation-references";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { Clapperboard, Download, Image as ImageIcon, MessageSquareText, MoreHorizontal, Pencil, Plus, Search, Sparkles, Trash2, X } from "lucide-react";
import { conversationTimestamp } from "./creation-conversations";
import { formatMessageTime } from "./creation-workspace-messages";
export function creationConversationBucket(updatedAt: string): "today" | "yesterday" | "week" | "earlier" {
const at = new Date(updatedAt).getTime();
if (!Number.isFinite(at)) return "earlier";
const nowStart = new Date();
nowStart.setHours(0, 0, 0, 0);
const targetStart = new Date(at);
targetStart.setHours(0, 0, 0, 0);
const days = Math.round((nowStart.getTime() - targetStart.getTime()) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 7) return "week";
return "earlier";
}
export const creationBucketLabels: Record<"today" | "yesterday" | "week" | "earlier", string> = { today: "今天", yesterday: "昨天", week: "近 7 天", earlier: "更早" };
export function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose, onSelect, onDelete, onRename }: { open: boolean; conversations: CreationConversation[]; activeId: string; onNew: () => void; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void; onRename: (conversation: CreationConversation, title: string) => void }) {
const [keyword, setKeyword] = useState("");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const skipRenameCommitRef = useRef(false);
const assistantName = useAppearanceStore((state) => state.appearance.brandName);
const exportUser = useUserStore((state) => state.user);
const { message: drawerToast } = App.useApp();
useEffect(() => {
if (!open) return;
setKeyword("");
setRenamingId(null);
setMenuOpenId(null);
}, [open]);
const commitRename = (conversation: CreationConversation) => {
const shouldSkip = skipRenameCommitRef.current;
skipRenameCommitRef.current = false;
const value = renameInputRef.current?.value.trim() || "";
setRenamingId(null);
if (shouldSkip || !value) return;
const original = conversation.title.trim() || "新创作";
if (value === original) return;
onRename(conversation, value);
};
const cancelRename = () => {
skipRenameCommitRef.current = true;
setRenamingId(null);
};
const beginRename = (conversation: CreationConversation) => {
skipRenameCommitRef.current = false;
setMenuOpenId(null);
setRenamingId(conversation.id);
};
const visibleConversations = useMemo(() => {
const query = keyword.trim().toLowerCase();
if (!query) return conversations;
return conversations.filter((conversation) => {
const latest = conversationPreviewMessage(conversation);
const searchable = [
conversation.title,
...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]),
latest?.mode ? modeLabels[latest.mode] : "创作",
formatConversationTime(conversation.updatedAt),
].filter(Boolean).join(" ").toLowerCase();
return searchable.includes(query);
});
}, [conversations, keyword]);
return <AppDrawer flush open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
<div className="creation-history-content">
<label className="creation-history-search">
<Search aria-hidden="true" />
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
</label>
<button type="button" className="creation-history-new" onClick={onNew}><span className="creation-history-new-icon"><Plus /></span><span className="creation-history-new-copy"><strong>新建创作</strong><small>开启一个新的创作对话</small></span></button>
{visibleConversations.length ? <ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
{visibleConversations.flatMap((conversation, index) => {
const showGroupHead = !keyword.trim() && (index === 0 || creationConversationBucket(conversation.updatedAt) !== creationConversationBucket(visibleConversations[index - 1].updatedAt));
const latest = conversationPreviewMessage(conversation);
const active = conversation.id === activeId;
const HistoryTypeIcon = latest?.mode === "video" ? Clapperboard : latest?.mode === "image" ? ImageIcon : latest?.mode === "text" ? MessageSquareText : Sparkles;
return [
showGroupHead ? <li key={`${conversation.id}-group`} className="creation-history-group-head"><h4>{creationBucketLabels[creationConversationBucket(conversation.updatedAt)]}</h4></li> : null,
<li key={conversation.id} className={active ? "is-active" : undefined}>
{renamingId === conversation.id ? (
<div className="creation-history-rename">
<input ref={renameInputRef} className="creation-history-rename-input" defaultValue={conversation.title.trim() || "新创作"} aria-label="重命名对话标题" autoFocus onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } else if (event.key === "Escape") { cancelRename(); } }} onBlur={() => commitRename(conversation)} />
</div>
) : (
<div className={menuOpenId === conversation.id ? "creation-history-row is-menu-open" : "creation-history-row"}>
<button type="button" className="creation-history-item-main" aria-current={active ? "page" : undefined} onClick={() => { setMenuOpenId(null); onSelect(conversation); }}>
<span className="creation-history-item-icon" aria-hidden="true"><HistoryTypeIcon /></span>
<span className="creation-history-item-text">
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
<span className="creation-history-snippet">{latest ? <><em>{latest.mode ? modeLabels[latest.mode] : "创作"}</em><span>{displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作"}</span></> : <><em>创作</em><span>还没有开始创作</span></>}</span>
</span>
</button>
<span className="creation-history-time-slot" aria-hidden={menuOpenId === conversation.id}><time dateTime={conversation.updatedAt}>{formatHistoryRelativeTime(conversation.updatedAt)}</time></span>
<Dropdown trigger={["click"]} placement="bottomRight" open={menuOpenId === conversation.id} onOpenChange={(open) => setMenuOpenId(open ? conversation.id : null)} overlayClassName="creation-history-menu-overlay" menu={{ items: [{ key: "rename", label: "重命名", icon: <Pencil /> }, { key: "export", label: "导出对话", icon: <Download /> }, { key: "delete", label: "删除对话", danger: true, icon: <Trash2 /> }], onClick: ({ key }) => { setMenuOpenId(null); if (key === "rename") { beginRename(conversation); } else if (key === "export") { downloadCreationConversation(conversation, assistantName, exportUser?.displayName || "你"); drawerToast.success("对话已导出为 Markdown"); } else { onDelete(conversation); } } }}>
<button type="button" className={menuOpenId === conversation.id ? "creation-history-more is-open" : "creation-history-more"} aria-label={`更多操作:${conversation.title.trim() || "新创作"}`} onClick={(event) => event.preventDefault()}><MoreHorizontal /></button>
</Dropdown>
</div>
)}
</li>,
];
})}
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}
</div>
</AppDrawer>;
}
export function buildConversationExportMarkdown(conversation: CreationConversation, assistantName: string, userName: string) {
const lines: string[] = [`# ${conversation.title.trim() || "新创作"}`, ""];
for (const message of conversation.messages) {
const stamp = formatMessageTime(message.createdAt);
const modeTag = message.mode && message.mode !== "text" ? (message.mode === "image" ? "[图像生成] " : "[视频生成] ") : "";
const speaker = message.role === "user" ? (userName || "我") : assistantName;
if (message.role === "user") {
const prompt = displayCreationPrompt(message.content, message.references || []).trim();
if (!prompt) continue;
lines.push(`## ${speaker} · ${stamp}`, "", prompt, "");
} else {
const body = (message.content || "").trim();
if (body) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}${body}`, "");
else if (message.resultUrls?.length) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}已生成,素材保留在项目中。`, "");
else continue;
if (message.reasoning?.trim()) lines.push("> 思考过程:", message.reasoning.trim(), "");
}
}
return lines.join("\n").trim() + "\n";
}
export function downloadCreationConversation(conversation: CreationConversation, assistantName: string, userName: string) {
const safeTitle = (conversation.title.trim() || "新创作").replace(/[\\/:*?"<>|]/g, "_").slice(0, 60) || "新创作";
const blob = new Blob([buildConversationExportMarkdown(conversation, assistantName, userName)], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${safeTitle}.md`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
export function conversationPreviewMessage(conversation: CreationConversation) {
let fallback: CreationMessage | undefined;
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
const message = conversation.messages[index];
if (!message.content.trim()) continue;
fallback ||= message;
if (message.role === "user") return message;
}
return fallback;
}
export function formatHistoryRelativeTime(value: string) {
const timestamp = conversationTimestamp(value);
if (!timestamp) return "";
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (seconds < 60) return "刚刚";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.round(hours / 24);
if (days < 7) return `${days} 天前`;
return historyDayFormatter.format(timestamp);
}
export function formatConversationTime(value: string) {
const timestamp = conversationTimestamp(value);
if (!timestamp) return "时间未知";
return conversationTimeFormatter.format(timestamp);
}

View File

@@ -0,0 +1,196 @@
// 创作页消息流:用户消息、媒体结果、生成中占位、引用素材与预览。
import { conversationTimeFormatter, type CreationMessage } from "./creation-types";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { Copy, Download, FileText, Film, Image as ImageIcon, Maximize2, Music2, Pencil, RefreshCw, Sparkles, UserRound, X } from "lucide-react";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { WorkingDots } from "@/components/ai/working-indicator";
import { generationErrorMessage } from "@/lib/generation-error";
import { useEffect, useState } from "react";
import { useCopyText } from "@/hooks/use-copy-text";
import { type CreationReference, displayCreationPrompt } from "./creation-references";
import { useUserStore } from "@/stores/use-user-store";
import { type CreationAttachment, creationAttachmentKind, creationMediaAspectRatio, type CreationMode } from "./creation-assets";
import { getResourceAccess, resolveResourceAccessURL, resolveResourceUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { Tooltip } from "@/components/ui/base/tooltip";
import { useAssetStore } from "@/stores/use-asset-store";
import { creationResultAssetIds, creationResultStorageKeys } from "@/lib/canvas/canvas-asset-handoff";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { Button } from "antd";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { AppModal } from "@/components/ui/product/app-modal";
export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage, onContinueCanvas, openingCanvas }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
if (item.role === "user") return <CreationUserMessage item={item} shotNumber={shotNumber} onEditUserMessage={onEditUserMessage} />;
const mode = item.mode || "text";
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
const heading =
mode !== "text" ? (
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : "视频生成"}</strong>{item.status === "pending" ? <span className="creation-message-progress-copy">{brandName}正在生成{mode === "video" ? "视频" : "图像"}……</span> : item.status === "done" ? <span className="creation-message-progress-copy">你的{mode === "video" ? "视频" : "图像"}已创建</span> : null}{item.status === "done" ? <button type="button" className="creation-message-variant-action" onClick={onCreateVariant}><RefreshCw />生成同款</button> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
) : (
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{brandName}</strong>{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
);
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
return <article className={`creation-assistant-message is-${mode}`}>
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onContinueCanvas={onContinueCanvas} openingCanvas={openingCanvas} /></GenerationToolCard>}
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
</article>;
}
export function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onEditUserMessage: (text: string) => void }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const copyText = useCopyText();
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
const user = useUserStore((state) => state.user);
const userAvatarUrl = user?.avatarUrl?.trim();
return <article className="creation-user-message">
<div className="creation-user-message-meta">{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}<strong>{user?.displayName || "你"}</strong><span className="creation-user-avatar">{userAvatarUrl ? <img src={userAvatarUrl} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" /> : <UserRound />}</span></div>
<div className="creation-user-message-copy-wrap"><p>{visiblePrompt}</p></div>
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
const kind = creationAttachmentKind(attachment);
const previewable = kind === "image" || kind === "video";
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
const previewUrl = kind === "image" ? imageUrl : url;
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
})}</div> : null}
<div className="creation-user-message-actions"><Tooltip title="复制提示词"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip><Tooltip title="编辑并重新发送"><button type="button" className="creation-user-message-edit" aria-label="编辑提示词" onClick={() => onEditUserMessage(visiblePrompt)}><Pencil /></button></Tooltip></div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</article>;
}
export function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas, openingCanvas }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const assets = useAssetStore((state) => state.assets);
const storedResultUrls = item.resultUrls || [];
const resultStorageKeys = item.resultStorageKeys?.length
? item.resultStorageKeys
: creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
const [resolvedResultUrls, setResolvedResultUrls] = useState(storedResultUrls);
useEffect(() => {
let active = true;
const timers: ReturnType<typeof setTimeout>[] = [];
const updateResultUrl = (index: number, url: string) => {
if (!active) return;
setResolvedResultUrls((current) => {
const next = [...current];
next[index] = url;
return next;
});
};
const refreshRemote = async (index: number, storageKey: string): Promise<void> => {
try {
const access = await getResourceAccess(storageKey, "display");
if (!active) return;
updateResultUrl(index, resolveResourceAccessURL(access.url));
const refreshAt = access.refreshAt ? new Date(access.refreshAt).getTime() : Number.NaN;
const expiresAt = access.expiresAt ? new Date(access.expiresAt).getTime() : Number.NaN;
const nextRefreshAt = Number.isFinite(refreshAt) ? refreshAt : expiresAt;
const delay = Number.isFinite(nextRefreshAt) ? Math.max(10_000, nextRefreshAt - Date.now()) : 4 * 60_000;
timers.push(setTimeout(() => void refreshRemote(index, storageKey), delay));
} catch {
if (active) timers.push(setTimeout(() => void refreshRemote(index, storageKey), 60_000));
}
};
if (!resultStorageKeys.length) {
setResolvedResultUrls(storedResultUrls);
return () => {
active = false;
};
}
// 历史记录只把 URL 当作短期 hint;真正恢复依赖稳定 storageKey,展示 URL 由访问缓存按需续签。
setResolvedResultUrls(storedResultUrls);
const resolver = item.mode === "video" ? resolveMediaUrl : resolveImageUrl;
void Promise.all(
resultStorageKeys.map(async (storageKey, index) => {
if (resourceIdFromStorageKey(storageKey)) {
await refreshRemote(index, storageKey);
return;
}
const url = await resolver(storageKey, storedResultUrls[index] || "");
updateResultUrl(index, url);
}),
).catch(() => {
// 保留当前页面已有的 URL hint;若已过期,页面会走明确的媒体空态而不是回退到平台正文代理。
});
return () => {
active = false;
timers.forEach((timer) => clearTimeout(timer));
};
}, [item.id, item.mode, resultStorageKeys.join("|"), storedResultUrls.join("|")]);
const alignedResultUrls = resultStorageKeys.length ? resolvedResultUrls : storedResultUrls;
const displayResultUrls = alignedResultUrls.filter(Boolean);
const resultAssetIds = alignedResultUrls.length || resultStorageKeys.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: alignedResultUrls, resultStorageKeys }) : [];
const expectedResultCount = resultStorageKeys.length || alignedResultUrls.length;
const canContinueWithResults = expectedResultCount > 0 && resultAssetIds.length === expectedResultCount;
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!expectedResultCount || !displayResultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const isVideo = item.mode === "video";
return <div className="creation-media-result">
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(displayResultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={displayResultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{displayResultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{displayResultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{displayResultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</div>;
}
export function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><WorkingDots dotSize={7} gap={3} minOpacity={0.3} /></span><span className="sr-only">{brandName}正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
}
export function CreationMessageReferences({ references }: { references: CreationReference[] }) {
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
return <span key={reference.id} className="creation-user-message-reference">{imageUrl && reference.kind === "video" ? <video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : imageUrl && reference.kind === "image" ? <CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" /> : <Icon />}<span>{reference.label}</span></span>;
})}</div>;
}
export function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
if (type === "image") return <CanvasImagePreview src={url} alt="媒体预览" onClose={onClose} />;
return <AppModal flush open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width="min(1160px, calc(100vw - 32px))" onCancel={onClose} className="creation-media-preview-modal">
{url ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : null}
</AppModal>;
}
export function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
item: CreationAttachment;
onPreview: (type: "image" | "video", url: string) => void;
onRemove: (id: string) => void;
}) {
const kind = creationAttachmentKind(item);
const previewable = kind === "image" || kind === "video";
const url = (kind === "video" ? item.url : item.previewUrl) || "";
const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : "";
const previewUrl = kind === "image" ? imageUrl : url;
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <CachedResourceImage storageKey={item.storageKey} src={imageUrl} alt={item.name} loading="lazy" decoding="async" fallback={<span className="creation-chat-file-icon"><ImageIcon /></span>} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
return <div className="creation-reference-card-content">
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
</div>;
}
export type CreationThinking = { title: string; hint: string; steps: string[]; activity: string };
export function thinkingFor(mode: CreationMode, brandName: string): CreationThinking {
if (mode === "image") return { title: "正在为你画这一镜", hint: `${brandName}正在理解你的构图意图,并把画面交给模型出图。`, steps: ["理解构图", "定调画风", "生成画面"], activity: "正在理解构图并把画面交给模型出图" };
if (mode === "text") return { title: "正在为你写这段", hint: `${brandName}正在梳理你的创作脉络,组织语言与结构。`, steps: ["梳理脉络", "组织语言", "输出段落"], activity: "正在梳理脉络并组织语言" };
return { title: "正在为你拍这一镜", hint: `${brandName}正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。`, steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"], activity: "正在按导演思路拆解镜头并渲染" };
}
export function formatMessageTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
}

View File

@@ -2,24 +2,14 @@ import { ImageSizePicker } from "@/components/image-size-picker";
import { imageResolutionUsesQuality } from "@/lib/image-size-presets";
import { createPortal } from "react-dom";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type ReactNode, type RefObject } from "react";
import { App, Button, Dropdown, Popover } from "antd";
import { AppDrawer } from "@/components/ui/product/app-drawer";
import { AppModal } from "@/components/ui/product/app-modal";
import { Button, Popover } from "antd";
import { useWorkspaceTopBarMount } from "@/components/layout/workspace-top-bar-extension";
import { Tooltip } from "@/components/ui/base/tooltip";
import { Reorder, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { ArrowDown, ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Copy, Download, FileText, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, MoreHorizontal, Music2, Pencil, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Trash2, UserRound, WandSparkles, Waves, X } from "lucide-react";
import { ArrowUp, Brain, ChevronDown, ChevronLeft, ChevronRight, Clapperboard, Clock3, Film, History, Image as ImageIcon, LoaderCircle, Maximize2, MessageSquareText, Minimize2, Plus, SlidersHorizontal, Trash2, WandSparkles, Waves, X } from "lucide-react";
import { AIMessageMarkdown } from "@/components/ai/ai-message-markdown";
import { GenerationToolCard, type GenerationToolStatus } from "@/components/ai/generation-tool-card";
import { WorkingDots, WorkingGlow } from "@/components/ai/working-indicator";
import { MessageReasoning } from "@/components/ai/message-reasoning";
import { creationResultAssetIds, creationResultStorageKeys } from "@/lib/canvas/canvas-asset-handoff";
import { generationErrorMessage } from "@/lib/generation-error";
import { WorkingGlow } from "@/components/ai/working-indicator";
import { formatVideoResolutionLabel as videoResolutionLabel } from "@/lib/video-generation-options";
import { useAssetStore } from "@/stores/use-asset-store";
import { CachedResourceImage } from "@/components/cached-resource-image";
import { CanvasImagePreview } from "@/components/canvas/canvas-image-preview";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
import { HoverBorderGradient } from "@/components/ui/aceternity/hover-border-gradient";
@@ -29,149 +19,29 @@ import { aceternityMotion } from "@/lib/aceternity-motion";
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
import { ASSET_CATEGORY_LABELS } from "@/lib/asset-category";
import { formatShotOrdinal } from "@/lib/shot-label";
import { useCopyText } from "@/hooks/use-copy-text";
import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers";
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
import type { Skill } from "@/services/api/skills";
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
import { getResourceAccess, resolveResourceAccessURL, resolveResourceUrl, resourceIdFromStorageKey } from "@/services/api/resources";
import { resolveMediaUrl } from "@/services/file-storage";
import { resolveImageUrl } from "@/services/image-storage";
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
import { useAppearanceStore } from "@/stores/use-appearance-store";
import { useUserStore } from "@/stores/use-user-store";
import type { PromptOptimizerProvider } from "@/lib/plugins/plugin-types";
import { displayCreationPrompt, type CreationReference } from "./creation-references";
import { creationAttachmentKind, creationMediaAspectRatio, removeCreationAttachment, type CreationAttachment, type CreationMode } from "./creation-assets";
import { conversationTimestamp, isImageAttachment, isVideoAttachment } from "./creation-conversations";
import { conversationTimeFormatter, countOptions, historyDayFormatter, messageTimeFormatter, modeLabels, qualityOptions, ratioOptions, resolutionOptions, shotScriptLabels, type CreationConversation, type CreationMessage, type CreationShotRailEntry, type CreationStatus } from "./creation-types";
import { type CreationReference } from "./creation-references";
import { creationAttachmentKind, type CreationAttachment, type CreationMode } from "./creation-assets";
import { countOptions, modeLabels, qualityOptions, resolutionOptions, type CreationShotRailEntry } from "./creation-types";
import "./creation-product.css";
import "./creation-scrollbars.css";
import { creationFeaturedWorks, inspirationSource } from "./creation-inspirations";
import { CreationAttachmentThumbnail, CreationMediaPreviewModal } from "./creation-workspace-messages";
export { CreationHistoryDrawer } from "./creation-workspace-history";
export { CreationMessageView } from "./creation-workspace-messages";
export { CreationEmptyBanner, CreationEmptySuggest, CreationFeaturedWorks } from "./creation-workspace-empty";
const CanvasPromptOptimizerDrawer = lazy(() => import("@/components/canvas/canvas-prompt-optimizer-drawer").then((module) => ({ default: module.CanvasPromptOptimizerDrawer })));
export const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
function creationConversationBucket(updatedAt: string): "today" | "yesterday" | "week" | "earlier" {
const at = new Date(updatedAt).getTime();
if (!Number.isFinite(at)) return "earlier";
const nowStart = new Date();
nowStart.setHours(0, 0, 0, 0);
const targetStart = new Date(at);
targetStart.setHours(0, 0, 0, 0);
const days = Math.round((nowStart.getTime() - targetStart.getTime()) / 86400000);
if (days <= 0) return "today";
if (days === 1) return "yesterday";
if (days < 7) return "week";
return "earlier";
}
const creationBucketLabels: Record<"today" | "yesterday" | "week" | "earlier", string> = { today: "今天", yesterday: "昨天", week: "近 7 天", earlier: "更早" };
export function CreationHistoryDrawer({ open, conversations, activeId, onNew, onClose, onSelect, onDelete, onRename }: { open: boolean; conversations: CreationConversation[]; activeId: string; onNew: () => void; onClose: () => void; onSelect: (conversation: CreationConversation) => void; onDelete: (conversation: CreationConversation) => void; onRename: (conversation: CreationConversation, title: string) => void }) {
const [keyword, setKeyword] = useState("");
const [renamingId, setRenamingId] = useState<string | null>(null);
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const skipRenameCommitRef = useRef(false);
const assistantName = useAppearanceStore((state) => state.appearance.brandName);
const exportUser = useUserStore((state) => state.user);
const { message: drawerToast } = App.useApp();
useEffect(() => {
if (!open) return;
setKeyword("");
setRenamingId(null);
setMenuOpenId(null);
}, [open]);
const commitRename = (conversation: CreationConversation) => {
const shouldSkip = skipRenameCommitRef.current;
skipRenameCommitRef.current = false;
const value = renameInputRef.current?.value.trim() || "";
setRenamingId(null);
if (shouldSkip || !value) return;
const original = conversation.title.trim() || "新创作";
if (value === original) return;
onRename(conversation, value);
};
const cancelRename = () => {
skipRenameCommitRef.current = true;
setRenamingId(null);
};
const beginRename = (conversation: CreationConversation) => {
skipRenameCommitRef.current = false;
setMenuOpenId(null);
setRenamingId(conversation.id);
};
const visibleConversations = useMemo(() => {
const query = keyword.trim().toLowerCase();
if (!query) return conversations;
return conversations.filter((conversation) => {
const latest = conversationPreviewMessage(conversation);
const searchable = [
conversation.title,
...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]),
latest?.mode ? modeLabels[latest.mode] : "创作",
formatConversationTime(conversation.updatedAt),
].filter(Boolean).join(" ").toLowerCase();
return searchable.includes(query);
});
}, [conversations, keyword]);
return <AppDrawer flush open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
<div className="creation-history-content">
<label className="creation-history-search">
<Search aria-hidden="true" />
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
</label>
<button type="button" className="creation-history-new" onClick={onNew}><span className="creation-history-new-icon"><Plus /></span><span className="creation-history-new-copy"><strong>新建创作</strong><small>开启一个新的创作对话</small></span></button>
{visibleConversations.length ? <ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
{visibleConversations.flatMap((conversation, index) => {
const showGroupHead = !keyword.trim() && (index === 0 || creationConversationBucket(conversation.updatedAt) !== creationConversationBucket(visibleConversations[index - 1].updatedAt));
const latest = conversationPreviewMessage(conversation);
const active = conversation.id === activeId;
const HistoryTypeIcon = latest?.mode === "video" ? Clapperboard : latest?.mode === "image" ? ImageIcon : latest?.mode === "text" ? MessageSquareText : Sparkles;
return [
showGroupHead ? <li key={`${conversation.id}-group`} className="creation-history-group-head"><h4>{creationBucketLabels[creationConversationBucket(conversation.updatedAt)]}</h4></li> : null,
<li key={conversation.id} className={active ? "is-active" : undefined}>
{renamingId === conversation.id ? (
<div className="creation-history-rename">
<input ref={renameInputRef} className="creation-history-rename-input" defaultValue={conversation.title.trim() || "新创作"} aria-label="重命名对话标题" autoFocus onFocus={(event) => event.currentTarget.select()} onKeyDown={(event) => { if (event.key === "Enter") { event.preventDefault(); event.currentTarget.blur(); } else if (event.key === "Escape") { cancelRename(); } }} onBlur={() => commitRename(conversation)} />
</div>
) : (
<div className={menuOpenId === conversation.id ? "creation-history-row is-menu-open" : "creation-history-row"}>
<button type="button" className="creation-history-item-main" aria-current={active ? "page" : undefined} onClick={() => { setMenuOpenId(null); onSelect(conversation); }}>
<span className="creation-history-item-icon" aria-hidden="true"><HistoryTypeIcon /></span>
<span className="creation-history-item-text">
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
<span className="creation-history-snippet">{latest ? <><em>{latest.mode ? modeLabels[latest.mode] : "创作"}</em><span>{displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作"}</span></> : <><em>创作</em><span>还没有开始创作</span></>}</span>
</span>
</button>
<span className="creation-history-time-slot" aria-hidden={menuOpenId === conversation.id}><time dateTime={conversation.updatedAt}>{formatHistoryRelativeTime(conversation.updatedAt)}</time></span>
<Dropdown trigger={["click"]} placement="bottomRight" open={menuOpenId === conversation.id} onOpenChange={(open) => setMenuOpenId(open ? conversation.id : null)} overlayClassName="creation-history-menu-overlay" menu={{ items: [{ key: "rename", label: "重命名", icon: <Pencil /> }, { key: "export", label: "导出对话", icon: <Download /> }, { key: "delete", label: "删除对话", danger: true, icon: <Trash2 /> }], onClick: ({ key }) => { setMenuOpenId(null); if (key === "rename") { beginRename(conversation); } else if (key === "export") { downloadCreationConversation(conversation, assistantName, exportUser?.displayName || "你"); drawerToast.success("对话已导出为 Markdown"); } else { onDelete(conversation); } } }}>
<button type="button" className={menuOpenId === conversation.id ? "creation-history-more is-open" : "creation-history-more"} aria-label={`更多操作:${conversation.title.trim() || "新创作"}`} onClick={(event) => event.preventDefault()}><MoreHorizontal /></button>
</Dropdown>
</div>
)}
</li>,
];
})}
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}
</div>
</AppDrawer>;
}
export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversation, onOpenHistory, onContinueCanvas, openingCanvas }: { shots: CreationShotRailEntry[]; onJumpToShot: (shot: CreationShotRailEntry) => void; onNewConversation: () => void; onOpenHistory: () => void; onContinueCanvas: () => void; openingCanvas: boolean }) {
const [railOpen, setRailOpen] = useState(false);
const railRef = useRef<HTMLDivElement>(null);
@@ -209,164 +79,6 @@ export function CreationWorkspaceToolbar({ shots, onJumpToShot, onNewConversatio
return toolbar;
}
export function CreationMessageView({ item, shotNumber, onRetryFailure, onCreateVariant, onEditUserMessage, onContinueCanvas, openingCanvas }: { item: CreationMessage; shotNumber: number; onRetryFailure: () => void; onCreateVariant: () => void; onEditUserMessage: (text: string) => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
if (item.role === "user") return <CreationUserMessage item={item} shotNumber={shotNumber} onEditUserMessage={onEditUserMessage} />;
const mode = item.mode || "text";
const stateLabel = item.status === "pending" ? "生成中" : item.status === "cancelled" ? "已停止" : item.status === "error" ? "生成失败" : "";
const heading =
mode !== "text" ? (
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{mode === "image" ? "图像生成" : "视频生成"}</strong>{item.status === "pending" ? <span className="creation-message-progress-copy">{brandName}正在生成{mode === "video" ? "视频" : "图像"}……</span> : item.status === "done" ? <span className="creation-message-progress-copy">你的{mode === "video" ? "视频" : "图像"}已创建</span> : null}{item.status === "done" ? <button type="button" className="creation-message-variant-action" onClick={onCreateVariant}><RefreshCw />生成同款</button> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
) : (
<>{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}<span className="creation-message-mark"><Sparkles /></span><strong>{brandName}</strong>{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}{stateLabel ? <span className={`creation-message-state is-${item.status}`}>{stateLabel}</span> : null}</>
);
const toolStatus: GenerationToolStatus = item.status === "pending" ? "running" : item.status === "error" ? "error" : item.status === "cancelled" ? "cancelled" : "completed";
return <article className={`creation-assistant-message is-${mode}`}>
{mode === "text" ? <><div className="creation-message-heading">{heading}</div>{item.reasoning ? <div className="creation-message-reasoning-wrap"><MessageReasoning reasoning={item.reasoning} isStreaming={item.status === "streaming"} /></div> : null}<div className="creation-message-content">{item.content ? <AIMessageMarkdown isStreaming={item.status === "streaming"}>{item.content}</AIMessageMarkdown> : <span style={{ display: "inline-flex", alignItems: "center", gap: 8 }}><WorkingDots dotSize={5} gap={2} /><span>正在生成…</span></span>}</div></> : <GenerationToolCard status={toolStatus} heading={heading}><MediaResult item={item} onRetryFailure={onRetryFailure} onCreateVariant={onCreateVariant} onContinueCanvas={onContinueCanvas} openingCanvas={openingCanvas} /></GenerationToolCard>}
{item.error && mode === "text" ? <div className="creation-message-error"><span>{generationErrorMessage(item.error)}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div> : null}
</article>;
}
function CreationUserMessage({ item, shotNumber, onEditUserMessage }: { item: CreationMessage; shotNumber: number; onEditUserMessage: (text: string) => void }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const copyText = useCopyText();
const visiblePrompt = displayCreationPrompt(item.content, item.references || []);
const user = useUserStore((state) => state.user);
const userAvatarUrl = user?.avatarUrl?.trim();
return <article className="creation-user-message">
<div className="creation-user-message-meta">{shotNumber > 0 ? <span className="creation-shot-badge">镜 {shotNumber}</span> : null}{item.createdAt ? <time dateTime={item.createdAt}>{formatMessageTime(item.createdAt)}</time> : null}<strong>{user?.displayName || "你"}</strong><span className="creation-user-avatar">{userAvatarUrl ? <img src={userAvatarUrl} alt="" referrerPolicy="no-referrer" loading="lazy" decoding="async" /> : <UserRound />}</span></div>
<div className="creation-user-message-copy-wrap"><p>{visiblePrompt}</p></div>
{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}
{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
const kind = creationAttachmentKind(attachment);
const previewable = kind === "image" || kind === "video";
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
const imageUrl = kind === "image" ? resolveResourceUrl(attachment.storageKey, url) : "";
const previewUrl = kind === "image" ? imageUrl : url;
return <button key={attachment.id} type="button" className={!previewable ? "is-file" : undefined} onClick={() => { if (!previewable) return; setPreviewType(kind === "video" ? "video" : "image"); setPreviewUrl(kind === "video" ? attachment.url || "" : previewUrl); }} aria-label={previewable ? `预览 ${attachment.name || "附件"}` : attachment.name || "附件"} disabled={previewable && !previewUrl}>{kind === "video" ? <video src={attachment.url || ""} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : kind === "image" ? <CachedResourceImage storageKey={attachment.storageKey} src={imageUrl} alt={attachment.name || "附件"} width={44} height={44} loading="lazy" decoding="async" /> : kind === "audio" ? <Music2 /> : <FileText />}{previewable ? <span aria-hidden="true"><Maximize2 /></span> : null}</button>;
})}</div> : null}
<div className="creation-user-message-actions"><Tooltip title="复制提示词"><button type="button" className="creation-user-message-copy" aria-label="复制提示词" onClick={() => copyText(visiblePrompt, "提示词已复制")}><Copy /></button></Tooltip><Tooltip title="编辑并重新发送"><button type="button" className="creation-user-message-edit" aria-label="编辑提示词" onClick={() => onEditUserMessage(visiblePrompt)}><Pencil /></button></Tooltip></div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</article>;
}
function MediaResult({ item, onRetryFailure, onCreateVariant, onContinueCanvas, openingCanvas }: { item: CreationMessage; onRetryFailure: () => void; onCreateVariant: () => void; onContinueCanvas: (ids?: string[]) => void; openingCanvas: boolean }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
const assets = useAssetStore((state) => state.assets);
const storedResultUrls = item.resultUrls || [];
const resultStorageKeys = item.resultStorageKeys?.length
? item.resultStorageKeys
: creationResultStorageKeys(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: storedResultUrls });
const [resolvedResultUrls, setResolvedResultUrls] = useState(storedResultUrls);
useEffect(() => {
let active = true;
const timers: ReturnType<typeof setTimeout>[] = [];
const updateResultUrl = (index: number, url: string) => {
if (!active) return;
setResolvedResultUrls((current) => {
const next = [...current];
next[index] = url;
return next;
});
};
const refreshRemote = async (index: number, storageKey: string): Promise<void> => {
try {
const access = await getResourceAccess(storageKey, "display");
if (!active) return;
updateResultUrl(index, resolveResourceAccessURL(access.url));
const refreshAt = access.refreshAt ? new Date(access.refreshAt).getTime() : Number.NaN;
const expiresAt = access.expiresAt ? new Date(access.expiresAt).getTime() : Number.NaN;
const nextRefreshAt = Number.isFinite(refreshAt) ? refreshAt : expiresAt;
const delay = Number.isFinite(nextRefreshAt) ? Math.max(10_000, nextRefreshAt - Date.now()) : 4 * 60_000;
timers.push(setTimeout(() => void refreshRemote(index, storageKey), delay));
} catch {
if (active) timers.push(setTimeout(() => void refreshRemote(index, storageKey), 60_000));
}
};
if (!resultStorageKeys.length) {
setResolvedResultUrls(storedResultUrls);
return () => {
active = false;
};
}
// 历史记录只把 URL 当作短期 hint;真正恢复依赖稳定 storageKey,展示 URL 由访问缓存按需续签。
setResolvedResultUrls(storedResultUrls);
const resolver = item.mode === "video" ? resolveMediaUrl : resolveImageUrl;
void Promise.all(
resultStorageKeys.map(async (storageKey, index) => {
if (resourceIdFromStorageKey(storageKey)) {
await refreshRemote(index, storageKey);
return;
}
const url = await resolver(storageKey, storedResultUrls[index] || "");
updateResultUrl(index, url);
}),
).catch(() => {
// 保留当前页面已有的 URL hint;若已过期,页面会走明确的媒体空态而不是回退到平台正文代理。
});
return () => {
active = false;
timers.forEach((timer) => clearTimeout(timer));
};
}, [item.id, item.mode, resultStorageKeys.join("|"), storedResultUrls.join("|")]);
const alignedResultUrls = resultStorageKeys.length ? resolvedResultUrls : storedResultUrls;
const displayResultUrls = alignedResultUrls.filter(Boolean);
const resultAssetIds = alignedResultUrls.length || resultStorageKeys.length ? creationResultAssetIds(assets, { messageId: item.id, taskIds: item.taskIds || [], resultUrls: alignedResultUrls, resultStorageKeys }) : [];
const expectedResultCount = resultStorageKeys.length || alignedResultUrls.length;
const canContinueWithResults = expectedResultCount > 0 && resultAssetIds.length === expectedResultCount;
if (item.status === "pending") return <CreationMediaPending mode={item.mode || "image"} ratio={item.settings?.ratio} />;
if ((item.status === "error" || item.status === "cancelled") && !expectedResultCount) return <div className="creation-media-error"><span>{item.status === "cancelled" ? item.content || "已停止" : generationErrorMessage(item.error || "生成失败")}</span><button type="button" onClick={onRetryFailure}><RefreshCw />重新生成</button></div>;
if (!expectedResultCount || !displayResultUrls.length) return <div className="creation-media-empty">没有返回可预览结果 <button type="button" onClick={onRetryFailure}>重试</button></div>;
const isVideo = item.mode === "video";
return <div className="creation-media-result">
{isVideo ? <button type="button" className="creation-video-result" onClick={() => { setPreviewType("video"); setPreviewUrl(displayResultUrls[0]); }} aria-label="预览生成视频"><video muted preload="metadata" src={displayResultUrls[0]} /><span><Maximize2 />预览视频</span></button> : <div className="creation-image-result-grid">{displayResultUrls.map((url) => <button key={url} type="button" className="creation-image-result" onClick={() => { setPreviewType("image"); setPreviewUrl(url); }} aria-label="预览生成图片"><img src={url} alt="生成结果" /><span><Maximize2 /></span></button>)}</div>}
<div className="creation-media-actions"><span>{isVideo ? "视频结果" : `${displayResultUrls.length} 张图片`}</span><Button type="link" size="small" loading={openingCanvas} disabled={!canContinueWithResults} title={canContinueWithResults ? undefined : "素材保存完成后才能转入画布"} onClick={() => onContinueCanvas(resultAssetIds)}>添加到画布</Button>{displayResultUrls.map((url, index) => <a key={`${url}-download`} href={url} download>{displayResultUrls.length > 1 ? `下载 ${index + 1}` : <><Download />下载</>}</a>)}</div>
<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} />
</div>;
}
function CreationMediaPending({ mode, ratio }: { mode: CreationMode; ratio?: string }) {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
return <div className={`creation-media-pending is-${mode}`} style={{ aspectRatio: creationMediaAspectRatio(ratio, mode) }} aria-live="polite"><span className="creation-media-pending-icon"><WorkingDots dotSize={7} gap={3} minOpacity={0.3} /></span><span className="sr-only">{brandName}正在生成{mode === "video" ? "视频" : "图像"}</span></div>;
}
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
return <div className="creation-user-message-references" aria-label="本次引用">{references.map((reference) => {
const Icon = reference.kind === "skill" ? Sparkles : reference.kind === "image" ? ImageIcon : reference.kind === "video" ? Film : reference.kind === "audio" ? Music2 : FileText;
const imageUrl = reference.kind === "image" ? resolveResourceUrl(reference.storageKey, reference.previewUrl) : reference.previewUrl;
return <span key={reference.id} className="creation-user-message-reference">{imageUrl && reference.kind === "video" ? <video src={imageUrl} muted playsInline preload="metadata" aria-label={reference.label} /> : imageUrl && reference.kind === "image" ? <CachedResourceImage storageKey={reference.storageKey} src={imageUrl} alt="" loading="lazy" decoding="async" /> : <Icon />}<span>{reference.label}</span></span>;
})}</div>;
}
function CreationMediaPreviewModal({ url, type, onClose }: { url: string; type: "image" | "video"; onClose: () => void }) {
if (type === "image") return <CanvasImagePreview src={url} alt="媒体预览" onClose={onClose} />;
return <AppModal flush open={Boolean(url)} title={null} footer={null} centered destroyOnHidden width="min(1160px, calc(100vw - 32px))" onCancel={onClose} className="creation-media-preview-modal">
{url ? <video controls autoPlay className="creation-media-preview-video" src={url} /> : null}
</AppModal>;
}
function CreationAttachmentThumbnail({ item, onPreview, onRemove }: {
item: CreationAttachment;
onPreview: (type: "image" | "video", url: string) => void;
onRemove: (id: string) => void;
}) {
const kind = creationAttachmentKind(item);
const previewable = kind === "image" || kind === "video";
const url = (kind === "video" ? item.url : item.previewUrl) || "";
const imageUrl = kind === "image" ? resolveResourceUrl(item.storageKey, item.previewUrl) : "";
const previewUrl = kind === "image" ? imageUrl : url;
const content = kind === "video" ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : kind === "image" ? <CachedResourceImage storageKey={item.storageKey} src={imageUrl} alt={item.name} loading="lazy" decoding="async" fallback={<span className="creation-chat-file-icon"><ImageIcon /></span>} /> : <span className="creation-chat-file-icon">{kind === "audio" ? <Music2 /> : <FileText />}<em>{item.name}</em></span>;
return <div className="creation-reference-card-content">
{previewable ? <button type="button" className="creation-reference-card-preview" onClick={() => onPreview(kind === "video" ? "video" : "image", previewUrl)} aria-label={`放大预览 ${item.name}`} disabled={!previewUrl}>{content}<span aria-hidden="true"><Maximize2 /></span></button> : <div className="creation-reference-card-preview is-file" aria-label={item.name}>{content}</div>}
<button type="button" className="creation-reference-card-remove" onPointerDownCapture={(event) => event.stopPropagation()} onMouseDownCapture={(event) => event.stopPropagation()} onClick={(event) => { event.stopPropagation(); onRemove(item.id); }} aria-label={`移除 ${item.name}`}><X /></button>
</div>;
}
type ComposerProps = {
variant: "empty" | "thread";
mode: CreationMode;
@@ -819,168 +531,6 @@ function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapability
</Popover>;
}
const creationEmptyBannerFrames = [
{ src: "/short-drama-styles/cyberpunk-neon.jpg", caption: "镜头01 · 雨夜霓虹" },
{ src: "/short-drama-styles/suspense-noir.jpg", caption: "镜头02 · 暗巷追逐" },
{ src: "/short-drama-styles/retro-hong-kong.jpg", caption: "镜头03 · 天台重逢" },
];
export function CreationEmptyBanner() {
const brandName = useAppearanceStore((state) => state.appearance.brandName);
return <div className="creation-empty-art" aria-hidden="true">
{creationEmptyBannerFrames.map((frame, index) => <figure key={frame.caption} className={`creation-empty-art-frame ${index === 1 ? "is-main" : index === 0 ? "is-back" : "is-front"}`}>
<img src={frame.src} alt="" />
<span>{frame.caption}</span>
</figure>)}
<span className="creation-empty-art-caption"><span>{brandName}</span>把每一帧,交给镜头导演</span>
</div>;
}
const creationEmptySuggestions: Array<{ mode: CreationMode; icon: typeof Clapperboard; title: string; hint: string; prompt: string; openLibrary?: boolean }> = [
{ mode: "video", icon: Clapperboard, title: "生成第一个镜头", hint: "描述画面、镜头运动与光线", prompt: "雨夜天台,镜头缓缓推近霓虹灯牌下的主角,她回眸看向镜头,强对比电影感布光" },
{ mode: "image", icon: ImageIcon, title: "从参考图开始", hint: "上传风格图,生成同风格画面", prompt: "", openLibrary: true },
{ mode: "text", icon: FileText, title: "续写故事", hint: "和 AI 讨论剧情、角色与对白", prompt: "帮我续写一个短剧故事,先聊聊剧情走向:" },
{ mode: "video", icon: Sparkles, title: "引用技能增强", hint: "@技能 调用分镜、配音等专业能力", prompt: "调用分镜技能,帮我规划这个镜头的拍摄方案:" },
];
export function CreationEmptySuggest({ onStartPrompt, onOpenLibrary }: { onStartPrompt: (mode: CreationMode, prompt: string) => void; onOpenLibrary: () => void }) {
const reducedMotion = useReducedMotion();
const [hovered, setHovered] = useState<string | null>(null);
return <LayoutGroup id="creation-empty-suggest">
<div className="creation-empty-suggest" aria-label="快捷创作入口">
{creationEmptySuggestions.map((item) => {
const Icon = item.icon;
const start = () => {
if (item.openLibrary) onOpenLibrary();
else onStartPrompt(item.mode, item.prompt);
};
return <motion.button
key={item.title}
type="button"
className="suggest-card"
onClick={start}
onHoverStart={() => setHovered(item.title)}
onHoverEnd={() => setHovered(null)}
whileHover={reducedMotion ? undefined : { y: -2 }}
transition={aceternityMotion.spring.surface}
>
{hovered === item.title ? (
<motion.span
layoutId="creation-suggest-hover"
className="suggest-card-hover"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.surface}
/>
) : null}
<span className="library-icon-tile suggest-icon"><Icon size={18} strokeWidth={2} /></span>
<span className="suggest-copy">
<strong>{item.title}</strong>
<span>{item.hint}</span>
</span>
</motion.button>;
})}
</div>
</LayoutGroup>;
}
export function CreationFeaturedWorks({ onStartPrompt }: { onStartPrompt: (mode: CreationMode, prompt: string) => void }) {
const [filter, setFilter] = useState<"all" | CreationMode>("all");
const [limit, setLimit] = useState(12);
const filtered = creationFeaturedWorks.filter((item) => filter === "all" || item.mode === filter);
return <section className="creation-featured-works" aria-labelledby="creation-featured-title">
<div className="creation-featured-heading">
<div><h2 id="creation-featured-title">精选灵感</h2></div>
<p>{creationFeaturedWorks.length} 个创意起点 · 点击填入提示词,不自动生成</p>
</div>
<div className="creation-inspiration-filters" role="group" aria-label="灵感类型">
{(["all", "video", "image", "text"] as const).map((value) => <button key={value} type="button" aria-pressed={filter === value} onClick={() => { setFilter(value); setLimit(12); }}>{value === "all" ? "全部灵感" : modeLabels[value]}<span>{creationFeaturedWorks.filter((item) => value === "all" || item.mode === value).length}</span></button>)}
</div>
<div className="creation-featured-layout">
{filtered.slice(0, limit).map((item, index) => <button key={item.title} type="button" className={`product-collection-card creation-featured-card ${index === 0 ? "is-featured-hero" : ""}`} onClick={() => onStartPrompt(item.mode, item.prompt)}>
<span className="creation-featured-media"><img src={item.image} alt="" loading="lazy" /><span className="creation-inspiration-overlay"><ArrowUp />使用这个创意</span></span>
<span className="creation-featured-copy"><strong>{item.title}</strong><span>{item.description}</span><em><Sparkles />{item.source ? "开源改编 · CC0" : "原创提示词"} · {modeLabels[item.mode]}</em></span>
</button>)}
</div>
<footer className="creation-inspiration-footer">{limit < filtered.length ? <Button onClick={() => setLimit((count) => count + 12)}>展开更多灵感<ChevronDown /></Button> : <span>已展示全部 {filtered.length} 个创意</span>}<details><summary>模板与封面来源</summary><p>{inspirationSource.notice}</p><a href={inspirationSource.repository} target="_blank" rel="noreferrer">awesome-chatgpt-prompts · CC0</a></details></footer>
</section>;
}
type CreationThinking = { title: string; hint: string; steps: string[]; activity: string };
function thinkingFor(mode: CreationMode, brandName: string): CreationThinking {
if (mode === "image") return { title: "正在为你画这一镜", hint: `${brandName}正在理解你的构图意图,并把画面交给模型出图。`, steps: ["理解构图", "定调画风", "生成画面"], activity: "正在理解构图并把画面交给模型出图" };
if (mode === "text") return { title: "正在为你写这段", hint: `${brandName}正在梳理你的创作脉络,组织语言与结构。`, steps: ["梳理脉络", "组织语言", "输出段落"], activity: "正在梳理脉络并组织语言" };
return { title: "正在为你拍这一镜", hint: `${brandName}正在拆解你的镜头脚本,设计运镜与光线,并交给模型渲染成片。`, steps: ["拆解镜头", "设计运镜", "定调布光", "渲染成片"], activity: "正在按导演思路拆解镜头并渲染" };
}
function formatMessageTime(value: string) {
const timestamp = new Date(value).getTime();
return Number.isFinite(timestamp) ? conversationTimeFormatter.format(timestamp) : "";
}
function buildConversationExportMarkdown(conversation: CreationConversation, assistantName: string, userName: string) {
const lines: string[] = [`# ${conversation.title.trim() || "新创作"}`, ""];
for (const message of conversation.messages) {
const stamp = formatMessageTime(message.createdAt);
const modeTag = message.mode && message.mode !== "text" ? (message.mode === "image" ? "[图像生成] " : "[视频生成] ") : "";
const speaker = message.role === "user" ? (userName || "我") : assistantName;
if (message.role === "user") {
const prompt = displayCreationPrompt(message.content, message.references || []).trim();
if (!prompt) continue;
lines.push(`## ${speaker} · ${stamp}`, "", prompt, "");
} else {
const body = (message.content || "").trim();
if (body) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}${body}`, "");
else if (message.resultUrls?.length) lines.push(`## ${speaker} · ${stamp}`, "", `${modeTag}已生成,素材保留在项目中。`, "");
else continue;
if (message.reasoning?.trim()) lines.push("> 思考过程:", message.reasoning.trim(), "");
}
}
return lines.join("\n").trim() + "\n";
}
function downloadCreationConversation(conversation: CreationConversation, assistantName: string, userName: string) {
const safeTitle = (conversation.title.trim() || "新创作").replace(/[\\/:*?"<>|]/g, "_").slice(0, 60) || "新创作";
const blob = new Blob([buildConversationExportMarkdown(conversation, assistantName, userName)], { type: "text/markdown;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `${safeTitle}.md`;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
}
function conversationPreviewMessage(conversation: CreationConversation) {
let fallback: CreationMessage | undefined;
for (let index = conversation.messages.length - 1; index >= 0; index -= 1) {
const message = conversation.messages[index];
if (!message.content.trim()) continue;
fallback ||= message;
if (message.role === "user") return message;
}
return fallback;
}
function formatHistoryRelativeTime(value: string) {
const timestamp = conversationTimestamp(value);
if (!timestamp) return "";
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (seconds < 60) return "刚刚";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes} 分钟前`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} 小时前`;
const days = Math.round(hours / 24);
if (days < 7) return `${days} 天前`;
return historyDayFormatter.format(timestamp);
}
function formatConversationTime(value: string) {
const timestamp = conversationTimestamp(value);
if (!timestamp) return "时间未知";
return conversationTimeFormatter.format(timestamp);
}
function ratioPreviewStyle(value: string) {
const [width, height] = value.replace("x", ":").split(":").map(Number);
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) return { width: 10, height: 10 };