Initial commit: OmniAI Web Frontend

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-02 12:38:01 +08:00
commit bedee3ba8d
183 changed files with 94805 additions and 0 deletions
@@ -0,0 +1,611 @@
import { memo, useCallback, useEffect, useRef, useState, type CSSProperties, type ChangeEvent } from "react";
import {
AppstoreOutlined,
CaretRightOutlined,
ClockCircleOutlined,
CopyOutlined,
DownloadOutlined,
FileTextOutlined,
FullscreenOutlined,
MutedOutlined,
PauseOutlined,
PictureOutlined,
ReloadOutlined,
SoundOutlined,
StopOutlined,
ThunderboltOutlined,
} from "@ant-design/icons";
import { assetClient } from "../../../api/assetClient";
import { communityClient } from "../../../api/communityClient";
import { saveAssetToLocalLibrary } from "../../assets/localAssetStore";
import { SmoothedProgressBar } from "../SmoothedProgressBar";
import { useSmoothedProgress } from "../../../hooks/useSmoothedProgress";
import { renderMarkdownBlocks } from "../markdownRenderer";
import { downloadResultAsset } from "../workbenchDownload";
import type { WorkbenchChatAttachment, WorkbenchChatMessage, WorkbenchResultActionPayload } from "../workbenchChatTypes";
// ─── Helpers ──────────────────────────────────────────────────────────────────
function cleanModelDisplayLabel(label: string) {
const channelName = "百炼";
return label.replace(new RegExp(`\\s*[(]\\s*${channelName}\\s*[)]`, "g"), "").trim();
}
function formatMediaTime(value: number) {
if (!Number.isFinite(value) || value <= 0) return "0:00";
const minutes = Math.floor(value / 60);
const seconds = Math.floor(value % 60);
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
// ─── MarkdownMessage ──────────────────────────────────────────────────────────
export const MarkdownMessage = memo(function MarkdownMessage({ text }: { text: string }) {
const normalized = text.trim();
if (!normalized) return null;
return <div className="ai-chat-markdown">{renderMarkdownBlocks(normalized)}</div>;
});
// ─── ImmersiveVideoPlayer ─────────────────────────────────────────────────────
export const ImmersiveVideoPlayer = memo(function ImmersiveVideoPlayer({
src,
title,
}: {
src: string;
title: string;
}) {
const videoRef = useRef<HTMLVideoElement | null>(null);
const frameRef = useRef<HTMLDivElement | null>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const [volume, setVolume] = useState(1);
const syncVideoState = useCallback(() => {
const video = videoRef.current;
if (!video) return;
setIsPlaying(!video.paused && !video.ended);
setIsMuted(video.muted);
setVolume(video.volume);
setCurrentTime(video.currentTime || 0);
setDuration(Number.isFinite(video.duration) ? video.duration : 0);
}, []);
useEffect(() => {
const video = videoRef.current;
if (!video) return undefined;
video.volume = volume;
syncVideoState();
const events: Array<keyof HTMLMediaElementEventMap> = [
"loadedmetadata",
"durationchange",
"timeupdate",
"play",
"pause",
"ended",
"volumechange",
];
events.forEach((eventName) => video.addEventListener(eventName, syncVideoState));
return () => events.forEach((eventName) => video.removeEventListener(eventName, syncVideoState));
}, [src, syncVideoState, volume]);
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(document.fullscreenElement === frameRef.current);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []);
const togglePlayback = useCallback(() => {
const video = videoRef.current;
if (!video) return;
if (video.paused || video.ended) {
void video.play().catch(() => {});
} else {
video.pause();
}
}, []);
const handleSeek = (event: ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current;
const nextTime = Number(event.currentTarget.value);
if (!video || !Number.isFinite(nextTime)) return;
video.currentTime = nextTime;
setCurrentTime(nextTime);
};
const handleVolumeChange = (event: ChangeEvent<HTMLInputElement>) => {
const video = videoRef.current;
const nextVolume = Number(event.currentTarget.value);
if (!video || !Number.isFinite(nextVolume)) return;
video.volume = nextVolume;
video.muted = nextVolume === 0;
setVolume(nextVolume);
setIsMuted(video.muted);
};
const toggleMute = useCallback(() => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
}, []);
const toggleFullscreen = useCallback(() => {
const frame = frameRef.current;
if (!frame) return;
if (document.fullscreenElement === frame) {
void document.exitFullscreen?.();
} else {
void frame.requestFullscreen?.();
}
}, []);
const progress = duration > 0 ? Math.min(100, Math.max(0, (currentTime / duration) * 100)) : 0;
const volumeProgress = Math.min(100, Math.max(0, volume * 100));
return (
<div
ref={frameRef}
className={`ai-chat-media-player${isPlaying ? " is-playing" : ""}${isFullscreen ? " is-fullscreen" : ""}`}
>
<div className="ai-chat-media-player__stage" onClick={togglePlayback}>
<video
ref={videoRef}
key={src}
src={src}
playsInline
preload="metadata"
aria-label={title}
/>
<span className="ai-chat-media-player__vignette" />
<button
type="button"
className="ai-chat-media-player__center-play"
onClick={(event) => {
event.stopPropagation();
togglePlayback();
}}
aria-label={isPlaying ? "Pause video" : "Play video"}
>
{isPlaying ? <PauseOutlined /> : <CaretRightOutlined />}
</button>
</div>
<div className="ai-chat-media-player__controls" onClick={(event) => event.stopPropagation()}>
<input
className="ai-chat-media-player__seek"
type="range"
min="0"
max={duration || 0}
step="0.01"
value={Math.min(currentTime, duration || currentTime)}
onChange={handleSeek}
disabled={duration <= 0}
aria-label="Video progress"
style={{ "--progress": `${progress}%` } as CSSProperties}
/>
<div className="ai-chat-media-player__bar">
<button type="button" onClick={togglePlayback} aria-label={isPlaying ? "Pause video" : "Play video"}>
{isPlaying ? <PauseOutlined /> : <CaretRightOutlined />}
</button>
<span className="ai-chat-media-player__time">
{formatMediaTime(currentTime)} / {formatMediaTime(duration)}
</span>
<span className="ai-chat-media-player__spacer" />
<button type="button" onClick={toggleMute} aria-label={isMuted ? "Unmute video" : "Mute video"}>
{isMuted || volume === 0 ? <MutedOutlined /> : <SoundOutlined />}
</button>
<input
className="ai-chat-media-player__volume"
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
aria-label="Video volume"
style={{ "--progress": `${isMuted ? 0 : volumeProgress}%` } as CSSProperties}
/>
<button type="button" onClick={toggleFullscreen} aria-label="Fullscreen video">
<FullscreenOutlined />
</button>
</div>
</div>
</div>
);
});
// ─── ResultCard ───────────────────────────────────────────────────────────────
export const ResultCard = memo(function ResultCard({
message,
onRegenerate,
onOpenMedia,
onSuperResolveVideo,
onSuperResolveImage,
onOpenResultInCanvas,
downloadFilenameBase,
}: {
message: WorkbenchChatMessage;
onRegenerate: (message: WorkbenchChatMessage) => void;
onOpenMedia: (item: WorkbenchChatAttachment) => void;
onSuperResolveVideo: (message: WorkbenchChatMessage) => void;
onSuperResolveImage: (message: WorkbenchChatMessage) => void;
onOpenResultInCanvas?: (payload: WorkbenchResultActionPayload) => void;
downloadFilenameBase?: string;
}) {
const [mediaRatio, setMediaRatio] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null);
const [isPublishing, setIsPublishing] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [mediaExpired, setMediaExpired] = useState(false);
const isVideo = message.resultType === "video";
const hasMedia = !!message.resultUrl;
const isSuperResolving =
hasMedia &&
message.status === "thinking" &&
message.taskStatusLabel?.includes("超分");
const promptText = message.prompt || (message.resultUrl ? "" : message.body);
const handleCopyPrompt = () => {
if (promptText) {
navigator.clipboard.writeText(promptText);
}
};
const specs = message.result?.specs || [];
const modelTag = cleanModelDisplayLabel(specs[0] || "");
const modeTag = specs.length > 1 ? specs[1] : "";
const mediaStyle = mediaRatio ? ({ "--media-ratio": mediaRatio } as CSSProperties) : undefined;
const durationTag = specs.find((spec) => /\b\d+s\b|秒/.test(spec)) || "5s";
const openGeneratedMedia = () => {
if (!message.resultUrl) return;
onOpenMedia({
kind: isVideo ? "video" : "image",
name: message.result?.title || (isVideo ? "生成视频" : "生成图片"),
token: message.taskId ? `#${message.taskId}` : "@结果",
previewUrl: message.resultUrl,
remoteUrl: message.resultUrl,
});
};
const resultTitle = message.result?.title || (isVideo ? "生成视频" : "生成图片");
const handleDownload = async () => {
if (!message.resultUrl || isDownloading) return;
setIsDownloading(true);
setActionNotice("正在准备下载...");
try {
const filenameBase = downloadFilenameBase || resultTitle;
const status = await downloadResultAsset(message.resultUrl, filenameBase, isVideo, message.resultOssKey ? undefined : message.taskId);
setActionNotice(status === "saved" ? "已保存到本地" : "已开始保存到本地");
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
setActionNotice("已取消保存");
} else {
setActionNotice(error instanceof Error ? error.message : "下载失败,请稍后重试");
}
} finally {
setIsDownloading(false);
}
};
const buildResultPayload = (): WorkbenchResultActionPayload | null => {
if (!message.resultUrl || !message.resultType) return null;
return {
title: resultTitle,
prompt: promptText,
resultUrl: message.resultUrl,
resultType: message.resultType,
taskId: message.taskId,
resultOriginalUrl: message.resultOriginalUrl,
resultOssKey: message.resultOssKey,
resultMimeType: message.resultMimeType,
};
};
const handleSaveAsset = async () => {
const payload = buildResultPayload();
if (!payload) return;
saveAssetToLocalLibrary({
type: payload.resultType === "video" ? "video" : "scene",
name: payload.title,
description: payload.prompt || "从生成结果保存的素材。",
imageUrl: payload.resultUrl,
tags: [payload.resultType === "video" ? "生成视频" : "生成图片", "工作台"],
});
try {
await assetClient.create({
type: payload.resultType === "video" ? "video" : "image",
name: payload.title,
description: payload.prompt || "从生成结果保存的素材。",
url: payload.resultUrl,
ossKey: payload.resultOssKey,
tags: [payload.resultType === "video" ? "生成视频" : "生成图片", "工作台"],
sourceTaskId: payload.taskId,
metadata: {
source: "workbench-result",
prompt: payload.prompt,
resultType: payload.resultType,
originalUrl: payload.resultOriginalUrl || null,
mimeType: payload.resultMimeType || null,
},
});
setActionNotice("已保存到服务器资产库");
} catch {
setActionNotice("已保存到本地资产库,登录后可同步服务器。");
}
};
const handleContinueInCanvas = () => {
const payload = buildResultPayload();
if (!payload) return;
onOpenResultInCanvas?.(payload);
setActionNotice("已打开画布继续编辑");
};
const handlePublish = async () => {
const payload = buildResultPayload();
if (!payload || isPublishing) return;
setIsPublishing(true);
setActionNotice(null);
try {
await communityClient.publishCase({
title: payload.title,
description: payload.prompt || "从工作台生成结果提交的社区作品。",
coverUrl: payload.resultUrl,
tags: [payload.resultType === "video" ? "视频" : "图片", "Web生成"],
metadata: {
source: "workbench-result",
taskId: payload.taskId,
prompt: payload.prompt,
resultType: payload.resultType,
resultOssKey: payload.resultOssKey || null,
resultOriginalUrl: payload.resultOriginalUrl || null,
resultMimeType: payload.resultMimeType || null,
},
assets: [
{
assetType: payload.resultType,
title: payload.title,
url: payload.resultUrl,
ossKey: payload.resultOssKey,
metadata: {
prompt: payload.prompt,
taskId: payload.taskId,
originalUrl: payload.resultOriginalUrl || null,
mimeType: payload.resultMimeType || null,
},
},
],
});
setActionNotice("已提交社区审核。");
} catch (error) {
setActionNotice(error instanceof Error ? error.message : "提交社区失败");
} finally {
setIsPublishing(false);
}
};
return (
<div className={`ai-chat-image-result-card result-card-enter${isVideo ? " is-video" : ""}`}>
{hasMedia ? (
<div className={`ai-chat-image-frame${isVideo ? " ai-chat-image-frame--video" : ""}${mediaExpired ? " is-expired" : ""}`} style={mediaStyle}>
{mediaExpired ? (
<div className="ai-chat-media-expired">
<span></span>
<button type="button" onClick={() => onRegenerate(message)}>
<ReloadOutlined />
</button>
</div>
) : isVideo ? (
<button type="button" className="ai-chat-video-poster" onClick={openGeneratedMedia}>
<video
src={message.resultUrl}
playsInline
muted
loop
preload="metadata"
onError={() => setMediaExpired(true)}
/>
<span className="ai-chat-video-poster__scrim" />
<span className="ai-chat-video-poster__play">
<CaretRightOutlined />
</span>
<span className="ai-chat-video-poster__duration">
<ClockCircleOutlined /> {durationTag}
</span>
</button>
) : (
<button type="button" className="ai-chat-image-preview-button" onClick={openGeneratedMedia}>
<img
src={message.resultUrl}
alt="生成结果"
onError={() => setMediaExpired(true)}
onLoad={(event) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
if (naturalWidth > 0 && naturalHeight > 0) {
setMediaRatio(`${naturalWidth} / ${naturalHeight}`);
}
}}
/>
</button>
)}
</div>
) : null}
<div className="ai-chat-image-result-meta">
<div className="ai-chat-image-result-meta__top">
<strong>{isVideo ? "视频已生成" : "图像已生成"}</strong>
<span>{message.taskStatusLabel || "Completed"}</span>
</div>
{promptText && <p className="ai-chat-image-result-meta__prompt">{promptText}</p>}
<div className="ai-chat-image-result-meta__chips">
{modelTag && <span className="ai-chat-chip">{modelTag}</span>}
{modeTag && <span className="ai-chat-chip">{modeTag}</span>}
</div>
{isSuperResolving ? (
<div className="ai-chat-image-result-progress" aria-label="视频超分进度">
<SmoothedProgressBar
progress={message.taskProgress ?? 18}
status={message.status || "thinking"}
label={message.taskStatusLabel || "超分处理中..."}
/>
</div>
) : null}
</div>
<div className="ai-chat-image-actions">
<button
type="button"
className="ai-chat-image-actions__primary"
onClick={() => onRegenerate(message)}
>
<ReloadOutlined />
</button>
<span className="ai-chat-image-actions__spacer" />
<button
type="button"
className="ai-chat-image-actions__icon"
onClick={() => void handleDownload()}
disabled={isDownloading}
title={isDownloading ? "正在准备下载" : "下载到本地"}
aria-label={isDownloading ? "正在准备下载" : "下载到本地"}
>
<DownloadOutlined />
</button>
{isVideo && message.resultUrl ? (
<button
type="button"
className="ai-chat-image-actions__icon ai-chat-image-actions__icon--super-resolution"
onClick={() => onSuperResolveVideo(message)}
disabled={isSuperResolving}
title="超分"
aria-label="超分"
>
<ThunderboltOutlined />
</button>
) : null}
{!isVideo && message.resultUrl ? (
<button
type="button"
className="ai-chat-image-actions__icon ai-chat-image-actions__icon--super-resolution"
onClick={() => onSuperResolveImage(message)}
disabled={isSuperResolving}
title="超分"
aria-label="超分"
>
<ThunderboltOutlined />
</button>
) : null}
<button type="button" className="ai-chat-image-actions__icon" onClick={handleCopyPrompt}>
<CopyOutlined />
</button>
</div>
{hasMedia ? (
<div className="ai-chat-result-flow">
<button type="button" onClick={() => void handleSaveAsset()}>
<AppstoreOutlined />
</button>
<button type="button" onClick={handleContinueInCanvas} disabled={!onOpenResultInCanvas}>
<PictureOutlined />
</button>
<button type="button" onClick={() => void handlePublish()} disabled={isPublishing}>
<ThunderboltOutlined /> {isPublishing ? "提交中..." : "发社区"}
</button>
{actionNotice ? <span>{actionNotice}</span> : null}
</div>
) : null}
</div>
);
});
// ─── ChatAttachmentPreview ────────────────────────────────────────────────────
export const ChatAttachmentPreview = memo(function ChatAttachmentPreview({
item,
onOpen,
}: {
item: WorkbenchChatAttachment;
onOpen: (item: WorkbenchChatAttachment) => void;
}) {
const [mediaFailed, setMediaFailed] = useState(false);
const urlExpired = !item.previewUrl || item.previewUrl.startsWith("blob:");
const hasMedia = !urlExpired && !mediaFailed && (item.kind === "image" || item.kind === "video");
return (
<button
type="button"
className={`ai-chat-attachment-thumb${!hasMedia ? " is-file" : ""}`}
onClick={() => hasMedia && onOpen(item)}
disabled={!hasMedia}
title={`${item.token} ${item.name}`}
>
<span className="ai-chat-attachment-thumb__media">
{hasMedia && item.kind === "image" ? (
<img src={item.previewUrl} alt={item.name} loading="lazy" onError={() => setMediaFailed(true)} />
) : hasMedia && item.kind === "video" ? (
<video src={item.previewUrl} muted playsInline onError={() => setMediaFailed(true)} />
) : (
<span className="ai-chat-attachment-thumb__icon">
{item.kind === "audio" ? <SoundOutlined /> : <FileTextOutlined />}
</span>
)}
</span>
<span className="ai-chat-attachment-thumb__label">{item.token}</span>
</button>
);
});
// ─── GenerationPendingCard ────────────────────────────────────────────────────
export const GenerationPendingCard = memo(function GenerationPendingCard({
message,
onStop,
}: {
message: WorkbenchChatMessage;
onStop?: () => void;
}) {
const specs = message.result?.specs || [];
const prompt = message.prompt || message.body;
const isVideo = message.mode === "video";
const smoothed = useSmoothedProgress(message.taskProgress ?? 5, message.status === "thinking" ? "running" : "completed");
return (
<div className={`ai-generation-pending-card${isVideo ? " is-video" : " is-image"}`}>
<div className="ai-generation-pending-card__stage">
<div className="ai-generation-pending-card__grid" />
<div className="ai-generation-pending-card__glow" />
<div className="ai-generation-pending-card__loader">
<span className="ai-generation-pending-card__ring" />
<em className="ai-generation-pending-card__percent">{smoothed}%</em>
</div>
<div className="ai-generation-pending-card__scan" />
</div>
<div className="ai-generation-pending-card__meta">
<div>
<strong>{message.taskStatusLabel || "Generating..."}</strong>
<span>{prompt}</span>
</div>
{specs.length > 0 && (
<div className="ai-generation-pending-card__chips">
{specs.slice(0, 3).map((spec) => (
<span key={spec}>{spec}</span>
))}
</div>
)}
{onStop && (
<button type="button" className="ai-generation-pending-card__stop" onClick={onStop} title="终止任务">
<StopOutlined />
</button>
)}
</div>
</div>
);
});