Files
omniai-web/src/features/workbench/WorkbenchPage.tsx
T
stringadmin 93a538d51d feat: UI animation enhancements across all major pages
P1 - Critical UX feedback:
- Add scale-in + slide-up-in entrance animations to profile popover and notification panel
- Port SmoothedProgressBar to EcommercePage (4 generation tools: clone, detail, tryOn, productSet)
- Add result-reveal stagger animation to ecommerce result grids
- Add heart-pop spring animation to CommunityPage favorite toggle

P2 - Visual polish:
- Add scroll-entrance IntersectionObserver animations for HomePage feature sections and experience section
- Add chat-message-in entrance animation to WorkbenchPage message rows
- Fix prefers-reduced-motion accessibility in WelcomeSplash canvas (skip animation, instant entry)

P3 - CSS consolidation:
- Remove conflicting .page-motion definition from legacy-pages.css (keep translateY version from legacy-components.css)
- Consolidate skeleton-shimmer: remove opacity-pulse keyframe from primitives.css, unify with gradient sweep
- Wire up --ease-spring token for heart-pop animation
- Add :active press states (scale 0.97) to topbar buttons, brand lockup

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 17:37:51 +08:00

3048 lines
119 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AppstoreOutlined,
ArrowDownOutlined,
ArrowUpOutlined,
CaretRightOutlined,
ClockCircleOutlined,
CloseOutlined,
CopyOutlined,
DeleteOutlined,
DownloadOutlined,
FullscreenOutlined,
LoadingOutlined,
MessageOutlined,
MutedOutlined,
PictureOutlined,
PauseOutlined,
PlusOutlined,
ReloadOutlined,
SendOutlined,
SettingOutlined,
StopOutlined,
ThunderboltOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type CSSProperties,
type ChangeEvent,
type KeyboardEvent,
type ReactNode,
type SyntheticEvent,
} from "react";
import type { WebGenerationPreviewTask, WebUserSession } from "../../types";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { claimGenerationSlot, getActiveGenerationTaskCount, getGenerationUserKey, releaseGenerationSlot } from "../../api/generationConcurrency";
import { preUploadReference, resolvePreUploadedUrl } from "../../api/referenceUploadService";
import { assetClient } from "../../api/assetClient";
import { communityClient } from "../../api/communityClient";
import { RechargeModal } from "../../components/RechargeModal/RechargeModal";
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
import type { CreatePreviewTaskInput } from "../../api/webGenerationGateway";
import type { WebProjectSummary } from "../../types";
import {
communityCaseToPromptCase,
getPromptCaseCardClassName,
type PromptCaseViewModel,
} from "../community/communityCaseUtils";
import ProjectSidebar from "./ProjectSidebar";
import {
ChatAttachmentPreview,
GenerationPendingCard,
ImmersiveVideoPlayer,
MarkdownMessage,
ResultCard,
} from "./components/WorkbenchChatCards";
import { renderMarkdownBlocks } from "./markdownRenderer";
import { downloadResultAsset } from "./workbenchDownload";
import { translateTaskError } from "../../utils/translateTaskError";
import { detectMentionTrigger } from "../../utils/mentionTrigger";
import {
isHappyHorseModel,
toHappyHorseDisplayModel,
} from "../../utils/happyHorseRouting";
import { isViduModel } from "../../utils/viduRouting";
import { isPixverseModel } from "../../utils/pixverseRouting";
import { resolveVideoRequestModel } from "../../utils/resolveVideoModel";
import { ENTERPRISE_DEFAULT_VIDEO_MODEL } from "../../utils/enterpriseVideoPolicy";
import {
getImageQualityOptions,
getDefaultImageQuality,
getVideoQualityOptions,
getDefaultVideoQuality,
getVideoQualityLabel,
} from "../../utils/modelOptions";
import { filterImageModelOptionsForSession } from "../../utils/imageModelVisibility";
import { persistWorkbenchResultAsset, type PersistedWorkbenchResultAsset } from "./workbenchResultPersistence";
import { SmoothedProgressBar } from "./SmoothedProgressBar";
import { useSmoothedProgress } from "../../hooks/useSmoothedProgress";
import {
type WorkbenchMode,
type ToolbarMenuId,
type ReferenceKind,
type WorkbenchOption,
type WorkbenchFieldGroup,
type ReferenceItem,
type PromptMentionItem,
type PromptMentionTokenRange,
type ChatAttachment,
type ChatMessage,
type DeleteDialogState,
type WorkbenchKeepaliveTask,
MODE_META,
MODE_OPTIONS,
IMAGE_MODEL_OPTIONS,
VIDEO_MODEL_OPTIONS,
RATIO_OPTIONS,
GRID_MODE_OPTIONS,
VIDEO_FRAME_OPTIONS,
VIDEO_DURATION_OPTIONS,
MESSAGE_STORAGE_KEY,
ACTIVE_CONVERSATION_STORAGE_KEY,
PROMPT_HISTORY_STORAGE_KEY,
TASK_KEEPALIVE_STORAGE_KEY,
WORKBENCH_TASK_STALE_MS,
WORKBENCH_TASK_MAX_POLL_FAILURES,
REFERENCE_IMAGE_COMPRESS_THRESHOLD,
REFERENCE_IMAGE_MAX_DIMENSION,
REFERENCE_IMAGE_INITIAL_QUALITY,
REFERENCE_IMAGE_MIN_QUALITY,
CHAT_MODEL,
CHAT_NATURAL_SYSTEM_PROMPT,
CHAT_TURN_STYLE_REMINDER,
NON_CONVERSATIONAL_ASSISTANT_TEXT,
getCachedRole,
getSessionUserId,
userKey,
createId,
formatWorkbenchTimestamp,
parseWorkbenchTimestampValue,
buildChatAttachments,
buildNaturalChatHistoryMessages,
getErrorText,
isAuthFailure,
isInsufficientBalance,
isInsufficientBalanceMessage,
isTransientMessage,
getPersistableMessages,
shouldPersistPatch,
buildAssistantResult,
} from "./workbenchConstants";
import {
readStoredMessages,
readStoredPromptHistory,
readStoredActiveConversationId,
persistActiveConversationId,
persistMessages,
clearWorkbenchLocalState,
persistPromptHistory,
buildRecoverableTaskFromMessage,
readStoredKeepaliveTasks,
persistKeepaliveTasks,
} from "./workbenchStorage";
import {
getRatioOptionClassName,
getSettingsGridColumnsClassName,
getReferenceAccept,
getReferenceUploadLabel,
getReferenceLimit,
getReferenceKindLabel,
getReferenceEmptyCopy,
hexToRgbTriplet,
inferReferenceKind,
disposeReferencePreview,
fileToDataUrl,
bytesToHex,
buildReferenceFingerprint,
canCompressReferenceImage,
compressReferenceImageIfNeeded,
buildReferenceToken,
resolveReferenceUrls,
} from "./workbenchReferenceUtils";
import {
renderPromptPreviewNodes,
getPromptMentionTokenRanges,
removePromptMentionTokenFromText,
removePromptTextRange,
} from "./workbenchMentionUtils";
import {
findPromptMentionRangeInside,
findPromptMentionRangeOverlap,
ReferenceInlinePreview,
ReferencePreview,
PromptPreviewLayer,
} from "./WorkbenchPromptPreview";
import { SelectChip, CompoundSelectChip, InlineOptionChip } from "./WorkbenchSelectChips";
export type { WorkbenchResultActionPayload } from "./workbenchConstants";
interface WorkbenchPageProps {
isAuthenticated: boolean;
session: WebUserSession | null;
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
onRefreshUsage?: () => void;
}
// ─── Component ───────────────────────────────────────────────────────────
// (All types, constants, helpers, and sub-components extracted to sibling modules)
// ─── REDUNDANT LOCAL DEFINITIONS REMOVED ─────────────────────────────
// The following block (originally ~1200 lines of types, constants, utility
// functions, and sub-components) has been extracted to sibling modules:
// workbenchConstants.ts, workbenchStorage.ts, workbenchReferenceUtils.ts,
// workbenchMentionUtils.tsx, WorkbenchPromptPreview.tsx, WorkbenchSelectChips.tsx
// and is now imported at the top of this file.
const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
chat: <MessageOutlined />,
image: <PictureOutlined />,
video: <VideoCameraOutlined />,
};
function WorkbenchPage({
isAuthenticated,
session,
onRequireLogin,
onOpenResultInCanvas,
onRefreshUsage,
}: WorkbenchPageProps) {
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const referenceInputRef = useRef<HTMLInputElement | null>(null);
const referenceRefsRef = useRef<HTMLDivElement | null>(null);
const toolbarRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesSurfaceRef = useRef<HTMLDivElement | null>(null);
const referenceItemsRef = useRef<ReferenceItem[]>([]);
const referenceTokenSequenceRef = useRef<Record<ReferenceKind, number>>({
image: 0,
video: 0,
audio: 0,
file: 0,
});
const generationAbortRef = useRef<AbortController | null>(null);
const activeConversationIdRef = useRef<number | null>(null);
const messagesRef = useRef<ChatMessage[]>([]);
const conversationMessagesCacheRef = useRef<Map<number, ChatMessage[]>>(new Map());
const skipConversationAutoSelectRef = useRef(false);
const keepaliveTasksRef = useRef<Record<string, WorkbenchKeepaliveTask>>(readStoredKeepaliveTasks());
const taskAbortControllersRef = useRef<Map<string, AbortController>>(new Map());
const lastScrollTopRef = useRef(0);
const shouldFollowNewMessagesRef = useRef(true);
const pendingScrollToLatestRef = useRef(true);
const renderedMessageIdsRef = useRef<string[]>([]);
const hasHandledInitialMessagesRef = useRef(false);
const [activeMode, setActiveMode] = useState<WorkbenchMode>("video");
const [inputValue, setInputValue] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(() => readStoredMessages());
const [promptHistory, setPromptHistory] = useState<string[]>(() => readStoredPromptHistory());
const [toolbarMenuId, setToolbarMenuId] = useState<ToolbarMenuId>(null);
const [referenceItems, setReferenceItems] = useState<ReferenceItem[]>([]);
const [referencePreviewOpen, setReferencePreviewOpen] = useState(false);
const [messagePreviewAttachment, setMessagePreviewAttachment] = useState<ChatAttachment | null>(null);
const [selectedPromptCase, setSelectedPromptCase] = useState<PromptCaseViewModel | null>(null);
const [serverPromptCases, setServerPromptCases] = useState<PromptCaseViewModel[]>([]);
const [promptCaseMeasuredRatios, setPromptCaseMeasuredRatios] = useState<Record<string, number>>({});
const [mentionPanelPlacement, setMentionPanelPlacement] = useState<"above" | "below">("above");
const [isGenerating, setIsGenerating] = useState(false);
const [generationStatus, setGenerationStatus] = useState("准备就绪");
const [showRechargeModal, setShowRechargeModal] = useState(false);
const [savedAssetMentionItems, setSavedAssetMentionItems] = useState<
Pick<ReferenceItem, "id" | "kind" | "name" | "previewUrl" | "remoteUrl" | "token">[]
>([]);
const [projectError, setProjectError] = useState<string | null>(null);
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [activeConversationId, setActiveConversationId] = useState<number | null>(() =>
readStoredActiveConversationId(readStoredMessages()),
);
const [sidebarCollapsed, setSidebarCollapsed] = useState(true);
const [deleteDialog, setDeleteDialog] = useState<DeleteDialogState | null>(null);
const [deleteSubmitting, setDeleteSubmitting] = useState(false);
const [, setGenerationProgress] = useState(0);
const [cursorIndex, setCursorIndex] = useState(0);
const [promptSelectionRange, setPromptSelectionRange] = useState({ start: 0, end: 0 });
const [mentionActiveIndex, setMentionActiveIndex] = useState(0);
const [composerHidden, setComposerHidden] = useState(false);
const [workspaceStarted, setWorkspaceStarted] = useState(false);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
}, []);
useEffect(() => {
let cancelled = false;
assetClient
.list()
.then((assets) => {
if (cancelled) return;
const items = assets
.filter((a) => a.url && (a.type === "scene" || a.type === "video" || a.type === "image" || a.type === "character" || a.type === "prop"))
.slice(0, 20)
.map((a, i) => {
const kind: ReferenceKind = a.type === "video" ? "video" : "image";
return {
id: `asset-${a.id}`,
kind,
name: a.name || `素材 ${i + 1}`,
previewUrl: a.url || undefined,
remoteUrl: a.url || undefined,
token: `@素材${i + 1}`,
};
});
setSavedAssetMentionItems(items);
})
.catch(() => {
// Silently ignore — saved assets are optional enhancement
});
return () => {
cancelled = true;
};
}, []);
const fallbackImageModelOptions = useMemo(
() => filterImageModelOptionsForSession(IMAGE_MODEL_OPTIONS, session),
[session],
);
const fallbackImageModelValue = fallbackImageModelOptions[0]?.value || IMAGE_MODEL_OPTIONS[0].value;
const [imageModelOptions, setImageModelOptions] = useState<WorkbenchOption[]>(() => fallbackImageModelOptions);
const [videoModelOptions, setVideoModelOptions] = useState<WorkbenchOption[]>(VIDEO_MODEL_OPTIONS);
const [imageModel, setImageModel] = useState(fallbackImageModelValue);
const [imageRatio, setImageRatio] = useState("16:9");
const [imageQuality, setImageQuality] = useState(() => getDefaultImageQuality(fallbackImageModelValue));
const [imageGridMode, setImageGridMode] = useState("single");
const [videoModel, setVideoModel] = useState(VIDEO_MODEL_OPTIONS[0].value);
const [videoFrameMode, setVideoFrameMode] = useState("omni");
const [videoRatio, setVideoRatio] = useState("16:9");
const [videoDuration, setVideoDuration] = useState("4");
const [videoQuality, setVideoQuality] = useState(() => getDefaultVideoQuality(VIDEO_MODEL_OPTIONS[0].value));
useEffect(() => {
let cancelled = false;
const applyImageModels = (models: WorkbenchOption[]) => {
const filteredImageModels = filterImageModelOptionsForSession(
models.length ? models : IMAGE_MODEL_OPTIONS,
session,
);
const nextImageModels = filteredImageModels.length ? filteredImageModels : fallbackImageModelOptions;
setImageModelOptions(nextImageModels);
setImageModel((current) => {
if (nextImageModels.some((item) => item.value === current)) return current;
const nextValue = nextImageModels[0]?.value || fallbackImageModelValue;
setImageQuality(getDefaultImageQuality(nextValue));
return nextValue;
});
};
if (!isAuthenticated) {
applyImageModels(IMAGE_MODEL_OPTIONS);
return () => {
cancelled = true;
};
}
modelCapabilitiesClient
.get()
.then((capabilities) => {
if (cancelled) return;
const nextVideoModels = VIDEO_MODEL_OPTIONS;
applyImageModels(capabilities.imageModels);
setVideoModelOptions(nextVideoModels);
setVideoModel((current) => {
const normalizedCurrent = toHappyHorseDisplayModel(current);
if (nextVideoModels.some((item) => item.value === normalizedCurrent)) return normalizedCurrent;
const nextValue = nextVideoModels[0]?.value || VIDEO_MODEL_OPTIONS[0].value;
setVideoQuality(getDefaultVideoQuality(nextValue));
return nextValue;
});
})
.catch(() => {});
return () => {
cancelled = true;
};
}, [fallbackImageModelValue, isAuthenticated, session]);
const toolTheme = MODE_META[activeMode];
const workbenchAccent = "#00ff88";
const hasConversationRecords = activeConversationId !== null || messages.length > 0;
const hasActivatedWorkspace = workspaceStarted || isGenerating || hasConversationRecords;
const referenceCount = referenceItems.length;
const activeVideoModelValue = toHappyHorseDisplayModel(videoModel);
const activeModelValue =
activeMode === "image" ? imageModel : activeMode === "video" ? activeVideoModelValue : CHAT_MODEL;
const activeModel =
activeMode === "image"
? imageModelOptions.find((item) => item.value === imageModel)?.label || imageModel
: activeMode === "video"
? videoModelOptions.find((item) => item.value === activeVideoModelValue)?.label || activeVideoModelValue
: "OmniChat";
const conversationRecords = useMemo<WebProjectSummary[]>(
() =>
conversations.map((conversation) => ({
id: String(conversation.id),
name: conversation.title,
description: null,
thumbnailUrl: null,
updatedAt: conversation.updatedAt,
storyboardCount: 0,
imageCount: 0,
videoCount: 0,
source: "server",
mode: conversation.mode,
})),
[conversations],
);
const hasSidebarRecords = conversationRecords.length > 0;
const activeConversationTitle = useMemo(() => {
if (!activeConversationId) return "";
return conversations.find((c) => c.id === activeConversationId)?.title || "";
}, [conversations, activeConversationId]);
const downloadFilenameBase = useMemo(() => {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
return activeConversationTitle ? `${activeConversationTitle}_${dateStr}` : dateStr;
}, [activeConversationTitle]);
useEffect(() => {
setSidebarCollapsed(!hasSidebarRecords);
}, [hasSidebarRecords]);
const imageQualityOptions = useMemo(() => getImageQualityOptions(imageModel), [imageModel]);
const videoQualityOptions = getVideoQualityOptions(videoModel);
const videoQualityLabel = getVideoQualityLabel(videoModel, videoQuality);
const imageSettingsSummary = `${imageRatio} / ${imageQuality}`;
const composerPlaceholder =
referenceItems.length > 0 ? `${toolTheme.placeholder},可输入 @ 引用参考内容` : toolTheme.placeholder;
const dropdownDirection = hasActivatedWorkspace ? "up" : "down";
const accentRgb = hexToRgbTriplet(workbenchAccent);
const themeVars = {
"--accent": workbenchAccent,
"--accent-rgb": accentRgb,
"--accent-muted": `rgba(${accentRgb}, 0.12)`,
"--border-accent": `rgba(${accentRgb}, 0.24)`,
"--accent-glow": `0 0 24px rgba(${accentRgb}, 0.22)`,
} as CSSProperties;
const scrollMessagesToLatest = useCallback((behavior: ScrollBehavior = "smooth") => {
const scroll = () => {
const surface = messagesSurfaceRef.current;
if (!surface) {
messagesEndRef.current?.scrollIntoView({ block: "end", behavior });
return;
}
setComposerHidden(false);
shouldFollowNewMessagesRef.current = true;
surface.scrollTo({ top: surface.scrollHeight, behavior });
lastScrollTopRef.current = surface.scrollTop;
};
window.requestAnimationFrame(() => {
scroll();
window.setTimeout(scroll, 80);
});
}, []);
const imageSettingGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
{
label: "比例",
value: imageRatio,
options: RATIO_OPTIONS,
onChange: setImageRatio,
kind: "ratio",
columns: 3,
icon: <AppstoreOutlined />,
},
{
label: "清晰度",
value: imageQuality,
options: imageQualityOptions,
onChange: setImageQuality,
kind: "pill",
columns: imageQualityOptions.length >= 3 ? 3 : 2,
icon: <PictureOutlined />,
},
],
[imageQuality, imageQualityOptions, imageRatio],
);
const videoRatioGroups = useMemo<WorkbenchFieldGroup[]>(
() => [
{
label: "比例",
value: videoRatio,
options: RATIO_OPTIONS.filter((item) => item.value !== "3:4"),
onChange: setVideoRatio,
kind: "ratio",
columns: 3,
icon: <AppstoreOutlined />,
},
],
[videoRatio],
);
const promptMentionOptions = useMemo(() => {
const refOptions = referenceItems.map((item) => ({
token: item.token,
id: item.id,
name: item.name,
kind: item.kind,
previewUrl: item.previewUrl,
remoteUrl: item.remoteUrl,
}));
const existingTokens = new Set(refOptions.map((o) => o.token));
const assetOptions = savedAssetMentionItems.filter((item) => !existingTokens.has(item.token));
return [...refOptions, ...assetOptions];
}, [referenceItems, savedAssetMentionItems]);
const promptCaseDisplayItems = useMemo(() => {
return serverPromptCases;
}, [serverPromptCases]);
const handlePromptCaseImageLoad = useCallback((itemId: string, event: SyntheticEvent<HTMLImageElement>) => {
const { naturalWidth, naturalHeight } = event.currentTarget;
if (!naturalWidth || !naturalHeight) return;
const measuredRatio = naturalWidth / naturalHeight;
setPromptCaseMeasuredRatios((current) => {
const previousRatio = current[itemId];
if (previousRatio && Math.abs(previousRatio - measuredRatio) < 0.01) return current;
return {
...current,
[itemId]: measuredRatio,
};
});
}, []);
const referenceUploadLabel = getReferenceUploadLabel(activeMode);
const referenceButtonLabel =
referenceItems.length > 0 ? (referencePreviewOpen ? "点击折叠" : "点击显示") : referenceUploadLabel;
const referenceLimit = getReferenceLimit(activeMode, videoFrameMode);
const promptBeforeCursor = inputValue.slice(0, cursorIndex);
const promptMentionMatch = detectMentionTrigger(promptBeforeCursor);
const promptMentionOpen = promptMentionMatch !== null;
const promptPreviewNodes = useMemo(
() => renderPromptPreviewNodes(inputValue, promptMentionOptions),
[inputValue, promptMentionOptions],
);
const promptMentionTokenRanges = useMemo(
() => getPromptMentionTokenRanges(inputValue, promptMentionOptions),
[inputValue, promptMentionOptions],
);
const hasPromptSelection = promptSelectionRange.start !== promptSelectionRange.end;
const showPromptPreview =
!hasPromptSelection &&
inputValue.length > 0 &&
promptPreviewNodes.length > 0 &&
/@(图片|视频|音频|附件|素材)\d+/.test(inputValue);
useEffect(() => {
let cancelled = false;
communityClient
.listApprovedCases({ limit: 100, tag: "生成页面社区", sort: "latest" })
.then((items) => {
if (cancelled) return;
setServerPromptCases(
items
.map(communityCaseToPromptCase)
.filter((item): item is PromptCaseViewModel => Boolean(item)),
);
})
.catch(() => {
if (!cancelled) {
setServerPromptCases([]);
}
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!videoQualityOptions.some((item) => item.value === videoQuality)) {
setVideoQuality(getDefaultVideoQuality(videoModel));
}
}, [videoModel, videoQuality, videoQualityOptions]);
useEffect(() => {
if (!messagePreviewAttachment) return undefined;
const handleKeyDown = (event: globalThis.KeyboardEvent) => {
if (event.key === "Escape") {
setMessagePreviewAttachment(null);
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [messagePreviewAttachment]);
useEffect(() => {
if (!promptMentionOpen) return undefined;
const updatePlacement = () => {
const anchor = textareaRef.current;
const rect = anchor?.getBoundingClientRect();
if (!rect) return;
const estimatedHeight = promptMentionOptions.length > 0 ? Math.min(240, 116 + promptMentionOptions.length * 44) : 152;
const topSpace = rect.top;
const bottomSpace = window.innerHeight - rect.bottom;
setMentionPanelPlacement(topSpace < estimatedHeight + 24 || bottomSpace > topSpace ? "below" : "above");
};
updatePlacement();
window.addEventListener("resize", updatePlacement);
window.addEventListener("scroll", updatePlacement, true);
return () => {
window.removeEventListener("resize", updatePlacement);
window.removeEventListener("scroll", updatePlacement, true);
};
}, [promptMentionOpen, promptMentionOptions.length]);
useEffect(() => {
if (!isAuthenticated) {
Object.values(keepaliveTasksRef.current).forEach((task) => {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
});
generationAbortRef.current?.abort();
generationAbortRef.current = null;
keepaliveTasksRef.current = {};
skipConversationAutoSelectRef.current = false;
clearWorkbenchLocalState();
referenceItemsRef.current.forEach((item) => disposeReferencePreview(item));
referenceItemsRef.current = [];
setConversations([]);
setActiveConversationId(null);
activeConversationIdRef.current = null;
conversationMessagesCacheRef.current.clear();
messagesRef.current = [];
setMessages([]);
setInputValue("");
setReferenceItems([]);
setReferencePreviewOpen(false);
setMessagePreviewAttachment(null);
setWorkspaceStarted(false);
setComposerHidden(false);
setIsGenerating(false);
setGenerationStatus("准备就绪");
setGenerationProgress(0);
setProjectError(null);
return;
}
}, [isAuthenticated]);
const loadConversations = useCallback(async () => {
if (!isAuthenticated) {
setConversations([]);
return;
}
try {
const list = await conversationClient.list();
setConversations(list);
const restoredId = activeConversationIdRef.current || readStoredActiveConversationId(messagesRef.current);
if (restoredId && list.some((conversation) => conversation.id === restoredId)) {
setActiveConversationId(restoredId);
activeConversationIdRef.current = restoredId;
persistActiveConversationId(restoredId);
setWorkspaceStarted(true);
} else if (restoredId && !list.some((conversation) => conversation.id === restoredId)) {
setActiveConversationId(null);
activeConversationIdRef.current = null;
persistActiveConversationId(null);
} else if (
list.length > 0 &&
!activeConversationIdRef.current &&
messagesRef.current.length === 0 &&
!skipConversationAutoSelectRef.current
) {
const nextConversationId = list[0].id;
setActiveConversationId(nextConversationId);
activeConversationIdRef.current = nextConversationId;
persistActiveConversationId(nextConversationId);
setWorkspaceStarted(true);
}
setProjectError(null);
} catch (error) {
if (isAuthFailure(error)) {
setProjectError(null);
return;
}
setProjectError(error instanceof Error ? error.message : String(error));
}
}, [isAuthenticated]);
const loadConversation = useCallback(async (conversationId: number) => {
try {
skipConversationAutoSelectRef.current = false;
const conversation = await conversationClient.get(conversationId);
const nextMessages = conversation.messages.filter((item): item is ChatMessage => {
return (
item &&
typeof item.id === "string" &&
(item.role === "user" || item.role === "assistant") &&
typeof item.body === "string"
);
}) as ChatMessage[];
setActiveConversationId(conversation.id);
activeConversationIdRef.current = conversation.id;
pendingScrollToLatestRef.current = true;
setMessages(nextMessages);
rememberConversationMessages(conversation.id, nextMessages);
setWorkspaceStarted(nextMessages.length > 0);
setComposerHidden(false);
setProjectError(null);
} catch (error) {
if (isAuthFailure(error)) {
setProjectError(null);
return;
}
setProjectError(error instanceof Error ? error.message : String(error));
}
}, []);
// Migrate orphaned localStorage messages (no conversationId) to server
const migrateOrphanedMessages = useCallback(async () => {
const stored = readStoredMessages();
if (stored.length === 0) return;
const hasConversationId = stored.some((m) => m.conversationId);
if (hasConversationId) return; // messages already linked to a server conversation
try {
const conv = await conversationClient.create(
stored[0]?.body?.slice(0, 20) || "历史对话",
stored[0]?.mode || "chat",
stored.map((m) => ({ ...m, conversationId: undefined })),
);
const migrated = stored.map((m) => ({ ...m, conversationId: conv.id }));
setMessages(migrated);
messagesRef.current = migrated;
rememberConversationMessages(conv.id, migrated);
setActiveConversationId(conv.id);
activeConversationIdRef.current = conv.id;
persistActiveConversationId(conv.id);
persistMessages(migrated);
void loadConversations();
} catch {
// Migration is best-effort; if it fails, keep using localStorage
}
}, [loadConversations]);
useEffect(() => {
if (!isAuthenticated) return;
void migrateOrphanedMessages();
}, [isAuthenticated, migrateOrphanedMessages]);
useEffect(() => {
void loadConversations();
}, [loadConversations]);
useEffect(() => {
if (!isAuthenticated || !activeConversationId || messages.length > 0) return;
void loadConversation(activeConversationId);
}, [activeConversationId, isAuthenticated, loadConversation, messages.length]);
const saveProjectMessages = useCallback(
async (nextMessages: ChatMessage[], saveReason = "web-chat-autosave") => {
const conversationId = activeConversationIdRef.current;
if (!conversationId) return;
const persistableMessages = getPersistableMessages(nextMessages);
if (persistableMessages.length === 0) {
return;
}
await conversationClient.update(conversationId, { messages: persistableMessages as never[] });
setProjectError(null);
void loadConversations();
return { saveReason };
},
[loadConversations],
);
const handleProjectSaveError = useCallback((error: unknown) => {
if (isAuthFailure(error)) {
setProjectError(null);
return;
}
setProjectError(getErrorText(error));
}, []);
const rememberConversationMessages = (conversationId: number, nextMessages: ChatMessage[]) => {
conversationMessagesCacheRef.current.set(conversationId, nextMessages);
};
const getActiveKeepaliveTask = (conversationId: number | null) => {
if (!conversationId) return undefined;
return Object.values(keepaliveTasksRef.current).find((task) => task.conversationId === conversationId);
};
const syncActiveGenerationUi = useCallback((conversationId: number | null = activeConversationIdRef.current) => {
const activeTask = getActiveKeepaliveTask(conversationId);
setIsGenerating(Boolean(activeTask));
if (activeTask) {
setGenerationStatus(activeTask.statusLabel);
setGenerationProgress(activeTask.progress);
} else {
setGenerationProgress(0);
}
}, []);
const patchConversationMessage = useCallback(
async (conversationId: number, messageId: string, patch: Partial<ChatMessage>) => {
let sourceMessages =
activeConversationIdRef.current === conversationId
? messagesRef.current
: conversationMessagesCacheRef.current.get(conversationId);
if (!sourceMessages) return;
let changed = false;
const nextMessages = sourceMessages.map((message) => {
if (message.id !== messageId) return message;
changed = true;
const nextPatch = { ...patch };
if (
typeof nextPatch.taskProgress === "number" &&
typeof message.taskProgress === "number" &&
nextPatch.taskProgress < message.taskProgress &&
nextPatch.status !== "failed"
) {
nextPatch.taskProgress = message.taskProgress;
}
return { ...message, ...nextPatch };
});
if (!changed) return;
conversationMessagesCacheRef.current.set(conversationId, nextMessages);
if (activeConversationIdRef.current === conversationId) {
setMessages(nextMessages);
}
if (shouldPersistPatch(patch)) {
await saveProjectMessages(nextMessages).catch(handleProjectSaveError);
}
},
[handleProjectSaveError, saveProjectMessages],
);
const upsertKeepaliveTask = (task: WorkbenchKeepaliveTask) => {
keepaliveTasksRef.current = { ...keepaliveTasksRef.current, [task.taskId]: task };
persistKeepaliveTasks(keepaliveTasksRef.current);
};
const removeKeepaliveTask = (taskId: string) => {
const removed = keepaliveTasksRef.current[taskId];
releaseGenerationSlot(removed?.concurrencySlotId);
const { [taskId]: _removed, ...rest } = keepaliveTasksRef.current;
keepaliveTasksRef.current = rest;
persistKeepaliveTasks(rest);
};
const runKeepalivePoll = useCallback(
(task: WorkbenchKeepaliveTask) => {
if (taskAbortControllersRef.current.has(task.taskId)) return;
let lastKnownProgress = Math.max(0, Number(task.progress || 0));
let taskPollFailures = 0;
const abortController = new AbortController();
taskAbortControllersRef.current.set(task.taskId, abortController);
if (activeConversationIdRef.current === task.conversationId) {
generationAbortRef.current = abortController;
setIsGenerating(true);
setGenerationStatus(task.statusLabel);
setGenerationProgress(lastKnownProgress);
}
const sleep = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
void (async () => {
try {
for (let attempt = 0; attempt < 220; attempt += 1) {
if (abortController.signal.aborted) return;
if (attempt > 0) await sleep(3000);
if (abortController.signal.aborted) return;
let status;
try {
status = await aiGenerationClient.getTaskStatus(task.taskId);
} catch {
taskPollFailures += 1;
if (taskPollFailures >= WORKBENCH_TASK_MAX_POLL_FAILURES) {
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: "任务状态连续查询失败,已停止等待。请重新生成或稍后在资产页查看。",
status: "failed",
taskProgress: 100,
taskStatusLabel: "任务异常",
});
removeKeepaliveTask(task.taskId);
return;
}
continue;
}
taskPollFailures = 0;
const currentMessageProgress =
activeConversationIdRef.current === task.conversationId
? messagesRef.current.find((message) => message.id === task.assistantMessageId)?.taskProgress || 0
: 0;
const serverProgress = Number(status.progress || 0);
const baseProgress = Number.isFinite(serverProgress) ? serverProgress : 0;
const progress = status.status === "completed"
? 100
: Math.min(99, Math.max(10, lastKnownProgress, currentMessageProgress, Math.round(baseProgress)));
lastKnownProgress = Math.max(lastKnownProgress, progress);
const isSuperResolveTask = task.operation === "video-super-resolution";
const statusLabel =
status.status === "pending"
? (isSuperResolveTask ? "超分排队中" : "任务排队中")
: status.status === "running"
? (isSuperResolveTask ? "超分处理中..." : "正在生成...")
: status.status === "completed"
? (isSuperResolveTask ? "超分完成" : "生成完成")
: (isSuperResolveTask ? "超分失败" : "生成失败");
const latestTask = {
...task,
progress,
statusLabel,
};
upsertKeepaliveTask(latestTask);
if (activeConversationIdRef.current === task.conversationId) {
setIsGenerating(status.status !== "completed" && status.status !== "failed");
setGenerationStatus(statusLabel);
setGenerationProgress(progress);
}
if (status.status === "completed" && status.resultUrl) {
const completedPatch: Partial<ChatMessage> = {
body: isSuperResolveTask
? "视频已完成超分,并已替换为高清版本"
: task.mode === "image" ? "图像已经生成完成。" : "视频已经生成完成。",
status: "completed",
taskProgress: 100,
taskStatusLabel: statusLabel,
resultUrl: status.resultUrl,
resultType: task.mode,
resultOriginalUrl: status.resultUrl,
};
if (!isSuperResolveTask) {
completedPatch.result = buildAssistantResult(task.mode, task.modelLabel, task.specs, task.referenceCount);
}
await patchConversationMessage(task.conversationId, task.assistantMessageId, completedPatch);
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
try {
if (status.resultUrl) {
const persistedResult = await persistWorkbenchResultAsset({
title: task.mode === "image" ? "生成图片" : isSuperResolveTask ? "超分视频" : "生成视频",
sourceUrl: status.resultUrl,
resultType: task.mode,
taskId: task.taskId,
});
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
resultUrl: persistedResult.url,
resultOriginalUrl: persistedResult.originalUrl,
resultOssKey: persistedResult.ossKey,
resultMimeType: persistedResult.mimeType,
});
}
} catch (error) {
console.warn("[workbench] result persistence skipped after visible completion:", error);
}
return;
}
if (status.status === "completed" && !status.resultUrl) {
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: "任务已结束,但没有返回可用资源。请重新生成。",
status: "failed",
taskProgress: 100,
taskStatusLabel: "结果缺失",
});
removeKeepaliveTask(task.taskId);
return;
}
if (status.status === "failed" || status.status === "cancelled") {
if (getCachedRole() === "admin") console.error("[轮询] 任务失败", { taskId: task.taskId, status, error: status.error });
if (isInsufficientBalanceMessage(status.error)) setShowRechargeModal(true);
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: translateTaskError(status.error),
status: "failed",
taskProgress: progress,
taskStatusLabel: statusLabel,
});
removeKeepaliveTask(task.taskId);
onRefreshUsage?.();
return;
}
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: isSuperResolveTask ? "视频超分任务已提交,正在增强画质..." : "任务已提交,正在生成中...",
status: "thinking",
taskProgress: progress,
taskStatusLabel: statusLabel,
});
}
await patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: task.operation === "video-super-resolution"
? "视频超分超时,请稍后重试"
: "生成超时,请稍后在资产页查看结果。",
status: "failed",
taskStatusLabel: task.operation === "video-super-resolution" ? "超分超时" : "生成超时",
});
removeKeepaliveTask(task.taskId);
} finally {
taskAbortControllersRef.current.delete(task.taskId);
if (generationAbortRef.current === abortController) {
generationAbortRef.current = null;
}
syncActiveGenerationUi();
}
})();
},
[patchConversationMessage, syncActiveGenerationUi],
);
useEffect(() => {
persistMessages(messages);
if (activeConversationIdRef.current) {
rememberConversationMessages(activeConversationIdRef.current, messages);
}
const previousMessageIds = renderedMessageIdsRef.current;
const currentMessageIds = messages.map((message) => message.id);
const messageListChanged =
currentMessageIds.length !== previousMessageIds.length ||
currentMessageIds.some((messageId, index) => messageId !== previousMessageIds[index]);
renderedMessageIdsRef.current = currentMessageIds;
messagesRef.current = messages;
const shouldForceScrollToLatest = pendingScrollToLatestRef.current && messages.length > 0;
if (shouldForceScrollToLatest) {
pendingScrollToLatestRef.current = false;
}
if (
shouldForceScrollToLatest ||
(hasHandledInitialMessagesRef.current && messageListChanged && shouldFollowNewMessagesRef.current)
) {
scrollMessagesToLatest(shouldForceScrollToLatest ? "auto" : "smooth");
}
hasHandledInitialMessagesRef.current = true;
}, [messages, scrollMessagesToLatest]);
useEffect(() => {
activeConversationIdRef.current = activeConversationId;
persistActiveConversationId(activeConversationId);
syncActiveGenerationUi(activeConversationId);
}, [activeConversationId, syncActiveGenerationUi]);
useEffect(() => {
if (!imageQualityOptions.some((option) => option.value === imageQuality)) {
setImageQuality(getDefaultImageQuality(imageModel));
}
}, [imageModel, imageQuality, imageQualityOptions]);
useEffect(() => {
if (activeMode !== "video" || videoFrameMode !== "start-end" || referenceItems.length <= 2) return;
setReferenceItems((current) => {
if (current.length <= 2) return current;
const removed = current.slice(2);
removed.forEach((item) => disposeReferencePreview(item));
return current.slice(0, 2);
});
}, [activeMode, referenceItems.length, videoFrameMode]);
useEffect(() => {
Object.values(keepaliveTasksRef.current).forEach((task) => runKeepalivePoll(task));
return () => {
taskAbortControllersRef.current.forEach((controller) => controller.abort());
taskAbortControllersRef.current.clear();
};
}, [runKeepalivePoll]);
useEffect(() => {
persistPromptHistory(promptHistory);
}, [promptHistory]);
const handleRefreshProject = useCallback(() => {
void loadConversations();
}, [loadConversations]);
const handleRenameProject = useCallback(
async (projectId: string, title: string) => {
const conversationId = Number(projectId);
if (!Number.isFinite(conversationId)) return;
try {
await conversationClient.update(conversationId, { title });
setConversations((prev) => prev.map((c) => c.id === conversationId ? { ...c, title } : c));
setProjectError(null);
} catch (error) {
if (isAuthFailure(error)) {
setProjectError(null);
return;
}
setProjectError(error instanceof Error ? error.message : String(error));
}
},
[],
);
const requestDeleteProject = useCallback(
(projectId: string) => {
const record = conversationRecords.find((item) => item.id === projectId);
setDeleteDialog({ projectId, title: record?.name || projectId });
},
[conversationRecords],
);
const confirmDeleteProject = useCallback(
async () => {
if (!deleteDialog || deleteSubmitting) return;
const conversationId = Number(deleteDialog.projectId);
if (!Number.isFinite(conversationId)) {
setDeleteDialog(null);
return;
}
setDeleteSubmitting(true);
try {
await conversationClient.delete(conversationId, { cleanupUserData: true });
const remainingConversations = conversations.filter((conversation) => conversation.id !== conversationId);
setConversations(remainingConversations);
if (conversationId === activeConversationId) {
Object.values(keepaliveTasksRef.current)
.filter((task) => task.conversationId === activeConversationIdRef.current)
.forEach((task) => {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
removeKeepaliveTask(task.taskId);
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
});
const currentIndex = conversations.findIndex((conversation) => conversation.id === conversationId);
const nextConversation =
remainingConversations[Math.max(0, currentIndex)] ||
remainingConversations[Math.max(0, currentIndex - 1)] ||
remainingConversations[0] ||
null;
if (nextConversation) {
skipConversationAutoSelectRef.current = false;
setActiveConversationId(nextConversation.id);
activeConversationIdRef.current = nextConversation.id;
persistActiveConversationId(nextConversation.id);
setWorkspaceStarted(true);
setComposerHidden(false);
void loadConversation(nextConversation.id);
} else {
skipConversationAutoSelectRef.current = true;
setActiveConversationId(null);
activeConversationIdRef.current = null;
persistActiveConversationId(null);
messagesRef.current = [];
setMessages([]);
setInputValue("");
setWorkspaceStarted(false);
setComposerHidden(false);
syncActiveGenerationUi(null);
}
}
setProjectError(null);
setDeleteDialog(null);
} catch (error) {
if (isAuthFailure(error)) {
setProjectError(null);
setDeleteDialog(null);
return;
}
setProjectError(error instanceof Error ? error.message : String(error));
} finally {
setDeleteSubmitting(false);
}
},
[activeConversationId, conversations, deleteDialog, deleteSubmitting, loadConversation, syncActiveGenerationUi],
);
const handleNewConversation = useCallback(() => {
skipConversationAutoSelectRef.current = true;
setActiveConversationId(null);
persistActiveConversationId(null);
messagesRef.current = [];
pendingScrollToLatestRef.current = true;
setMessages([]);
setInputValue("");
setWorkspaceStarted(false);
setComposerHidden(false);
syncActiveGenerationUi(null);
activeConversationIdRef.current = null;
}, [syncActiveGenerationUi]);
const handleSelectProject = useCallback((id: string) => {
if (!id) {
handleNewConversation();
return;
}
const conversationId = Number(id);
if (Number.isFinite(conversationId)) {
skipConversationAutoSelectRef.current = false;
void loadConversation(conversationId);
}
}, [handleNewConversation, loadConversation]);
const handleDeleteConversation = useCallback(async (id: number) => {
try {
await conversationClient.delete(id, { cleanupUserData: true });
setConversations((prev) => prev.filter((c) => c.id !== id));
if (activeConversationId === id) {
setActiveConversationId(null);
activeConversationIdRef.current = null;
persistActiveConversationId(null);
setMessages([]);
setWorkspaceStarted(false);
setComposerHidden(false);
syncActiveGenerationUi(null);
}
} catch {}
}, [activeConversationId, syncActiveGenerationUi]);
const handleRenameConversation = useCallback(async (id: number, title: string) => {
try {
await conversationClient.update(id, { title });
setConversations((prev) => prev.map((c) => c.id === id ? { ...c, title } : c));
} catch {}
}, []);
useEffect(() => {
referenceItemsRef.current = referenceItems;
}, [referenceItems]);
const focusPromptAt = useCallback((index: number) => {
const nextIndex = Math.max(0, Math.min(inputValue.length, index));
setCursorIndex(nextIndex);
setPromptSelectionRange({ start: nextIndex, end: nextIndex });
window.requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(nextIndex, nextIndex);
});
}, [inputValue.length]);
const normalizePromptSelection = useCallback(
(selectionStart: number, selectionEnd: number) => {
if (promptMentionTokenRanges.length === 0) {
return { start: selectionStart, end: selectionEnd };
}
if (selectionStart === selectionEnd) {
const range = findPromptMentionRangeInside(selectionStart, promptMentionTokenRanges);
if (!range) return { start: selectionStart, end: selectionEnd };
return { start: range.end, end: range.end };
}
const selectionMin = Math.min(selectionStart, selectionEnd);
const selectionMax = Math.max(selectionStart, selectionEnd);
const overlappedRange = findPromptMentionRangeOverlap(selectionMin, selectionMax, promptMentionTokenRanges);
if (overlappedRange) {
return { start: overlappedRange.end, end: overlappedRange.end };
}
const startRange = findPromptMentionRangeInside(selectionStart, promptMentionTokenRanges);
const endRange = findPromptMentionRangeInside(selectionEnd, promptMentionTokenRanges);
const nextStart = startRange ? startRange.start : selectionStart;
const nextEnd = endRange ? endRange.end : selectionEnd;
return { start: Math.min(nextStart, nextEnd), end: Math.max(nextStart, nextEnd) };
},
[promptMentionTokenRanges],
);
useEffect(
() => () => {
referenceItemsRef.current.forEach((item) => {
disposeReferencePreview(item);
});
},
[],
);
useEffect(() => {
if (!toolbarMenuId) return undefined;
const handlePointerDown = (event: PointerEvent) => {
if (!toolbarRef.current?.contains(event.target as Node)) {
setToolbarMenuId(null);
}
};
window.addEventListener("pointerdown", handlePointerDown);
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [toolbarMenuId]);
useEffect(() => {
if (!referencePreviewOpen) return undefined;
const handlePointerDown = (event: PointerEvent) => {
if (!referenceRefsRef.current?.contains(event.target as Node)) {
setReferencePreviewOpen(false);
}
};
window.addEventListener("pointerdown", handlePointerDown);
return () => window.removeEventListener("pointerdown", handlePointerDown);
}, [referencePreviewOpen]);
useEffect(() => {
if (referenceItems.length === 0 && referencePreviewOpen) {
setReferencePreviewOpen(false);
}
}, [referenceItems.length, referencePreviewOpen]);
useEffect(() => {
if (referenceItems.length === 0) {
referenceTokenSequenceRef.current = {
image: 0,
video: 0,
audio: 0,
file: 0,
};
}
}, [referenceItems.length]);
useEffect(() => {
if (referenceItems.length === 0) return;
if (referenceItems.length < referenceLimit) return;
setReferencePreviewOpen(true);
}, [referenceItems.length, referenceLimit]);
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${Math.min(Math.max(el.scrollHeight, 42), 120)}px`;
}, [inputValue]);
useEffect(() => {
const surface = messagesSurfaceRef.current;
if (!surface) return;
const scrollDeltaThreshold = 8;
const edgeThreshold = 3;
const syncComposerVisibility = () => {
const top = surface.scrollTop;
const atTop = top <= edgeThreshold;
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
shouldFollowNewMessagesRef.current = atBottom;
setComposerHidden(!(atTop || atBottom));
lastScrollTopRef.current = top;
};
lastScrollTopRef.current = surface.scrollTop;
syncComposerVisibility();
const handleScroll = () => {
const top = surface.scrollTop;
const delta = top - lastScrollTopRef.current;
const atTop = top <= edgeThreshold;
const atBottom = top + surface.clientHeight >= surface.scrollHeight - edgeThreshold;
shouldFollowNewMessagesRef.current = atBottom;
if (atTop || atBottom) {
setComposerHidden(false);
} else if (Math.abs(delta) > scrollDeltaThreshold) {
setComposerHidden(true);
}
lastScrollTopRef.current = top;
};
surface.addEventListener("scroll", handleScroll, { passive: true });
return () => surface.removeEventListener("scroll", handleScroll);
}, [hasActivatedWorkspace]);
const scrollMessagesSurface = useCallback((direction: "top" | "bottom") => {
const surface = messagesSurfaceRef.current;
if (!surface) return;
const top = direction === "top" ? 0 : surface.scrollHeight;
setComposerHidden(false);
surface.scrollTo({ top, behavior: "smooth" });
}, []);
const closeToolbarMenus = () => setToolbarMenuId(null);
const toggleToolbarMenu = (menuId: Exclude<ToolbarMenuId, null>) => {
setToolbarMenuId((current) => (current === menuId ? null : menuId));
};
const handleModeChange = (mode: WorkbenchMode) => {
setActiveMode(mode);
setToolbarMenuId(null);
setReferencePreviewOpen(false);
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handlePromptChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
const selection = normalizePromptSelection(event.target.selectionStart, event.target.selectionEnd);
setInputValue(event.target.value);
setCursorIndex(selection.start);
setPromptSelectionRange(selection);
if (selection.start !== event.target.selectionStart || selection.end !== event.target.selectionEnd) {
window.requestAnimationFrame(() => event.target.setSelectionRange(selection.start, selection.end));
}
};
const handlePromptSelectionChange = (event: SyntheticEvent<HTMLTextAreaElement>) => {
const { selectionStart, selectionEnd } = event.currentTarget;
const selection = normalizePromptSelection(selectionStart, selectionEnd);
setCursorIndex(selection.start);
setPromptSelectionRange(selection);
if (selection.start !== selectionStart || selection.end !== selectionEnd) {
const target = event.currentTarget;
window.requestAnimationFrame(() => target.setSelectionRange(selection.start, selection.end));
}
};
const handlePromptScroll = (event: SyntheticEvent<HTMLTextAreaElement>) => {
const ta = event.currentTarget;
const highlight = ta.parentElement?.querySelector<HTMLElement>(".wb-composer__highlight");
if (highlight) highlight.scrollTop = ta.scrollTop;
};
const removeReferenceItem = (id: string) => {
const target = referenceItems.find((item) => item.id === id);
setReferenceItems((current) => {
if (target) disposeReferencePreview(target);
return current.filter((item) => item.id !== id);
});
if (target?.token) {
const nextValue = removePromptMentionTokenFromText(inputValue, target.token);
if (nextValue !== inputValue) {
const nextCursor = Math.min(cursorIndex, nextValue.length);
setInputValue(nextValue);
setCursorIndex(nextCursor);
setPromptSelectionRange({ start: nextCursor, end: nextCursor });
window.requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(nextCursor, nextCursor);
});
}
}
};
const applyReferenceRemoteUrl = (id: string, remoteUrl: string) => {
const nextItems = referenceItemsRef.current.map((item) =>
item.id === id ? { ...item, remoteUrl } : item,
);
referenceItemsRef.current = nextItems;
setReferenceItems(nextItems);
};
const handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
if (files.length === 0) return;
const existingFingerprints = new Set(
referenceItemsRef.current
.map((item) => item.fingerprint)
.filter((fingerprint): fingerprint is string => Boolean(fingerprint)),
);
const selectedFingerprints = new Set<string>();
const preparedItems: Array<Omit<ReferenceItem, "id" | "token">> = [];
for (const file of files) {
try {
const kind = inferReferenceKind(file, activeMode);
const fingerprint = await buildReferenceFingerprint(file, kind);
if (kind === "image") {
if (existingFingerprints.has(fingerprint) || selectedFingerprints.has(fingerprint)) {
continue;
}
selectedFingerprints.add(fingerprint);
}
const result = kind === "image" ? await compressReferenceImageIfNeeded(file) : { file, compressed: false };
const canPreview = kind === "image" || kind === "video";
preparedItems.push({
kind,
name: file.name,
file: result.file,
previewUrl: canPreview ? URL.createObjectURL(result.file) : undefined,
fingerprint,
originalSize: file.size,
compressed: result.compressed,
});
} catch {
continue;
}
}
if (preparedItems.length > 0) {
const currentItems = referenceItemsRef.current;
const currentFingerprints = new Set(
currentItems
.map((item) => item.fingerprint)
.filter((fingerprint): fingerprint is string => Boolean(fingerprint)),
);
const acceptedItems: ReferenceItem[] = [];
const nextTokenSequence = { ...referenceTokenSequenceRef.current };
preparedItems.forEach((item) => {
if (currentItems.length + acceptedItems.length >= referenceLimit) {
disposeReferencePreview(item);
return;
}
if (item.kind === "image" && item.fingerprint && currentFingerprints.has(item.fingerprint)) {
disposeReferencePreview(item);
return;
}
if (item.fingerprint) currentFingerprints.add(item.fingerprint);
const nextTokenIndex = nextTokenSequence[item.kind] + 1;
nextTokenSequence[item.kind] = nextTokenIndex;
acceptedItems.push({
...item,
id: createId("ref"),
token: buildReferenceToken(item.kind, nextTokenIndex),
});
});
if (acceptedItems.length > 0) {
referenceTokenSequenceRef.current = nextTokenSequence;
const nextReferenceItems = [...currentItems, ...acceptedItems];
referenceItemsRef.current = nextReferenceItems;
setReferenceItems(nextReferenceItems);
acceptedItems.forEach((item) => {
if (item.file && (item.kind === "image" || item.kind === "video")) {
void preUploadReference(item.file, item.name, item.fingerprint).then((url) => {
if (url) applyReferenceRemoteUrl(item.id, url);
});
}
});
}
}
setToolbarMenuId(null);
setReferencePreviewOpen(preparedItems.length > 0 || referenceItemsRef.current.length > 0);
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const insertPromptMention = (token: string) => {
const rawBefore = inputValue.slice(0, cursorIndex);
const after = inputValue.slice(cursorIndex);
const match = detectMentionTrigger(rawBefore);
const before = match
? rawBefore.slice(0, match.atIndex) + token
: `${rawBefore}${rawBefore && !/\s$/.test(rawBefore) ? " " : ""}${token}`;
const spacer = after.length === 0 || !/^\s/.test(after) ? " " : "";
const nextValue = `${before}${spacer}${after}`;
const nextCursor = before.length + spacer.length;
setInputValue(nextValue);
setCursorIndex(nextCursor);
setPromptSelectionRange({ start: nextCursor, end: nextCursor });
setMentionActiveIndex(0);
setToolbarMenuId(null);
setReferencePreviewOpen(false);
const selectedOption = promptMentionOptions.find((o) => o.token === token);
if (selectedOption && selectedOption.id.startsWith("asset-")) {
const alreadyAdded = referenceItemsRef.current.some((r) => r.id === selectedOption.id);
if (!alreadyAdded) {
const newItem: ReferenceItem = {
id: selectedOption.id,
kind: selectedOption.kind as ReferenceKind,
name: selectedOption.name,
previewUrl: selectedOption.previewUrl,
remoteUrl: selectedOption.remoteUrl,
token: selectedOption.token,
};
const nextItems = [...referenceItemsRef.current, newItem];
referenceItemsRef.current = nextItems;
setReferenceItems(nextItems);
}
}
window.requestAnimationFrame(() => {
textareaRef.current?.focus();
textareaRef.current?.setSelectionRange(nextCursor, nextCursor);
});
};
const buildTaskPrompt = (trimmedPrompt: string) => {
const specLines =
activeMode === "image"
? [`模式:${MODE_META.image.label}`, `模型:${activeModel}`, `比例:${imageRatio}`, `清晰度:${imageQuality}`, `多宫格:${GRID_MODE_OPTIONS.find((item) => item.value === imageGridMode)?.label || imageGridMode}`]
: activeMode === "video"
? [`模式:${MODE_META.video.label}`, `模型:${activeModel}`, `生成方式:${VIDEO_FRAME_OPTIONS.find((item) => item.value === videoFrameMode)?.label || videoFrameMode}`, `比例:${videoRatio}`, `时长:${videoDuration}`, `清晰度:${videoQualityLabel}`]
: [`模式:${MODE_META.chat.label}`];
const referenceLines = referenceItems.map((item) => {
const token = item.token;
return `${token}${item.name}`;
});
return [trimmedPrompt, "", ...specLines, ...(referenceLines.length ? ["", "参考素材:", ...referenceLines] : [])].join("\n");
};
const getCurrentSpecs = () => {
if (activeMode === "image") {
return [
activeModel,
imageSettingsSummary,
GRID_MODE_OPTIONS.find((item) => item.value === imageGridMode)?.label || imageGridMode,
];
}
if (activeMode === "video") {
return [
activeModel,
VIDEO_FRAME_OPTIONS.find((item) => item.value === videoFrameMode)?.label || videoFrameMode,
`${videoRatio} / ${videoDuration}s / ${videoQualityLabel}`,
];
}
return ["工作流拆解", "脚本与分镜", "可复制节点"];
};
const updateAssistantMessage = (id: string, patch: Partial<ChatMessage>) => {
const nextMessages = messagesRef.current.map((message) => {
if (message.id !== id) return message;
const nextPatch = { ...patch };
if (
typeof nextPatch.taskProgress === "number" &&
typeof message.taskProgress === "number" &&
nextPatch.taskProgress < message.taskProgress &&
nextPatch.status !== "failed"
) {
nextPatch.taskProgress = message.taskProgress;
}
return { ...message, ...nextPatch };
});
messagesRef.current = nextMessages;
if (activeConversationIdRef.current) {
rememberConversationMessages(activeConversationIdRef.current, nextMessages);
}
setMessages(nextMessages);
return nextMessages;
};
const persistConversationAfterResult = (conversationId: number, nextMessages: ChatMessage[]) => {
rememberConversationMessages(conversationId, nextMessages);
void saveProjectMessages(nextMessages, "web-chat-result").catch(handleProjectSaveError);
};
const handleSendAction = async (promptOverride?: string) => {
const trimmedPrompt = (promptOverride ?? inputValue).trim();
if (!trimmedPrompt) return;
const userKey = getGenerationUserKey(session?.user.id);
if (activeMode !== "chat" && getActiveGenerationTaskCount(userKey) >= 3) return;
setReferencePreviewOpen(false);
let conversationId = activeConversationIdRef.current ?? activeConversationId;
let baseMessages =
conversationId && workspaceStarted
? messages
: conversationId
? conversationMessagesCacheRef.current.get(conversationId) || []
: [];
const taskInput: CreatePreviewTaskInput = {
title: `${MODE_META[activeMode].label} 创作任务`,
type: MODE_META[activeMode].taskType,
prompt: buildTaskPrompt(trimmedPrompt),
params: activeMode === "image"
? { model: activeModelValue, ratio: imageRatio, quality: imageQuality, gridMode: imageGridMode }
: activeMode === "video"
? {
model: toHappyHorseDisplayModel(activeModelValue),
ratio: videoRatio,
quality: videoQuality,
resolution: videoQuality,
duration: Number(videoDuration),
frameMode: videoFrameMode,
muted: false,
hasReferenceVideo: referenceItems.some((item) => item.kind === "video"),
}
: { model: activeModelValue },
};
if (activeMode === "image" && getCachedRole() === "admin") {
console.log("[ai/workbench-image-submit]", {
model: taskInput.params?.model,
ratio: taskInput.params?.ratio,
quality: taskInput.params?.quality,
gridMode: taskInput.params?.gridMode,
referenceCount: referenceItems.length,
promptLength: taskInput.prompt.length,
requestWillStart: isAuthenticated,
authRequired: !isAuthenticated,
});
}
if (!isAuthenticated) {
onRequireLogin(taskInput);
return;
}
if (conversationId && !conversationMessagesCacheRef.current.has(conversationId) && messages.length === 0) {
try {
const conversation = await conversationClient.get(conversationId);
const restoredMessages = conversation.messages.filter((item): item is ChatMessage => {
return (
item &&
typeof item.id === "string" &&
(item.role === "user" || item.role === "assistant") &&
typeof item.body === "string"
);
}) as ChatMessage[];
baseMessages = restoredMessages;
rememberConversationMessages(conversation.id, restoredMessages);
setActiveConversationId(conversation.id);
activeConversationIdRef.current = conversation.id;
} catch {
conversationId = null;
activeConversationIdRef.current = null;
setActiveConversationId(null);
persistActiveConversationId(null);
baseMessages = [];
}
}
if (conversationId) {
const conv = conversations.find((c) => c.id === conversationId);
if (conv?.title === "New conversation" || conv?.title.startsWith("新对话")) {
const newTitle = trimmedPrompt.slice(0, 20);
conversationClient.update(conversationId, { title: newTitle }).catch((err) => {
console.warn("[chat] conversation rename failed:", err?.message || err);
});
setConversations((prev) => prev.map((c) => c.id === conversationId ? { ...c, title: newTitle } : c));
}
}
const attachments = buildChatAttachments(referenceItems);
const messageCreatedAt = formatWorkbenchTimestamp();
const userMessage: ChatMessage = {
id: createId("user"),
role: "user",
author: "你",
mode: activeMode,
body: trimmedPrompt,
prompt: trimmedPrompt,
createdAt: messageCreatedAt,
conversationId: conversationId || undefined,
attachments,
};
const assistantMessageId = createId("assistant");
const assistantMessage: ChatMessage = {
id: assistantMessageId,
role: "assistant",
author: MODE_META[activeMode].label,
mode: activeMode,
prompt: trimmedPrompt,
body: activeMode === "chat" ? "我先看一下上下文,马上接上。" : "正在读取当前模式、模型、规格和参考素材,准备创建生成任务。",
createdAt: messageCreatedAt,
status: "thinking",
conversationId: conversationId || undefined,
taskProgress: 28,
taskStatusLabel: activeMode === "chat" ? "正在整理思路" : "Creating generation task",
};
setInputValue("");
setPromptHistory((current) => [trimmedPrompt, ...current.filter((item) => item !== trimmedPrompt)].slice(0, 20));
setWorkspaceStarted(true);
setComposerHidden(false);
const nextMessages = [...baseMessages, userMessage, assistantMessage];
messagesRef.current = nextMessages;
setMessages(nextMessages);
if (conversationId) {
rememberConversationMessages(conversationId, nextMessages);
}
setIsGenerating(true);
setGenerationStatus("正在创建生成任务");
setGenerationProgress(28);
const abortController = new AbortController();
generationAbortRef.current = abortController;
let keepaliveStarted = false;
let claimedGenerationSlotId: string | undefined;
let releaseClaimedGenerationSlot: (() => void) | null = null;
try {
if (activeMode === "image" || activeMode === "video") {
let taskId: string;
const isVideoFilteredModel =
activeMode === "video" && (isHappyHorseModel(taskInput.params?.model) || isViduModel(taskInput.params?.model) || isPixverseModel(taskInput.params?.model));
const requestReferenceItems = isVideoFilteredModel
? referenceItems.filter((item) => item.kind === "image")
: referenceItems;
if (isVideoFilteredModel) {
const droppedVideoCount = referenceItems.length - requestReferenceItems.length;
if (droppedVideoCount > 0 && conversationId) {
await patchConversationMessage(conversationId, userMessage.id, {
body: `${userMessage.body}\n\n⚠ ${droppedVideoCount} 个视频参考素材已被忽略(当前模型仅支持图片参考)。`,
});
}
}
const refUrls = await resolveReferenceUrls(requestReferenceItems);
const latestReferenceItems = [...referenceItemsRef.current];
setReferenceItems(latestReferenceItems);
const uploadedAttachments = buildChatAttachments(latestReferenceItems);
if (uploadedAttachments.length) {
const refreshedMessages = messagesRef.current.map((message) =>
message.id === userMessage.id ? { ...message, attachments: uploadedAttachments } : message,
);
messagesRef.current = refreshedMessages;
setMessages(refreshedMessages);
if (conversationId) rememberConversationMessages(conversationId, refreshedMessages);
}
claimedGenerationSlotId = createId("workbench-generation");
releaseClaimedGenerationSlot = claimGenerationSlot({
userKey: getGenerationUserKey(session?.user.id),
kind: activeMode,
id: claimedGenerationSlotId,
});
if (activeMode === "image") {
const result = await aiGenerationClient.createImageTask({
conversationId: conversationId || undefined,
model: taskInput.params?.model || fallbackImageModelValue,
prompt: trimmedPrompt,
ratio: taskInput.params?.ratio || imageRatio || "16:9",
quality: taskInput.params?.quality || "1K",
gridMode: taskInput.params?.gridMode || "single",
referenceUrls: refUrls.length ? refUrls : undefined,
});
taskId = result.taskId;
} else {
let requestModel = resolveVideoRequestModel({
model: taskInput.params?.model || ENTERPRISE_DEFAULT_VIDEO_MODEL,
referenceUrls: refUrls,
});
const result = await aiGenerationClient.createVideoTask({
conversationId: conversationId || undefined,
model: requestModel,
prompt: trimmedPrompt,
ratio: taskInput.params?.ratio || "16:9",
duration: taskInput.params?.duration || 5,
quality: taskInput.params?.quality || taskInput.params?.resolution || "1080P",
resolution: taskInput.params?.resolution || taskInput.params?.quality || "1080P",
frameMode: taskInput.params?.frameMode || "start-end",
referenceUrls: refUrls.length ? refUrls : undefined,
muted: taskInput.params?.muted ?? false,
hasReferenceVideo: requestReferenceItems.some((item) => item.kind === "video"),
});
taskId = result.taskId;
}
onRefreshUsage?.();
if (!conversationId) {
const taskSpecs = buildAssistantResult(activeMode, activeModel, getCurrentSpecs(), referenceItems.length);
const submissionSourceMessages = messagesRef.current.length ? messagesRef.current : nextMessages;
const submittedMessages = submissionSourceMessages.map((message) => {
const base = { ...message, conversationId: 0 };
if (message.id !== assistantMessageId) return base;
return {
...base,
body: "Task submitted, generating...",
status: "thinking" as const,
taskId,
taskProgress: 30,
taskStatusLabel: "Task submitted, generating...",
resultType: activeMode,
result: taskSpecs,
};
});
const conv = await conversationClient.create(
trimmedPrompt.slice(0, 20) || "新对话",
activeMode,
submittedMessages.map((message) => ({ ...message, conversationId: undefined })),
);
conversationId = conv.id;
skipConversationAutoSelectRef.current = false;
setActiveConversationId(conv.id);
activeConversationIdRef.current = conv.id;
setConversations((prev) => [conv, ...prev.filter((item) => item.id !== conv.id)]);
const boundMessages = submittedMessages.map((message) => ({ ...message, conversationId: conv.id }));
messagesRef.current = boundMessages;
setMessages(boundMessages);
rememberConversationMessages(conv.id, boundMessages);
await aiGenerationClient.bindTaskToConversation(taskId, conv.id).catch((error) => {
setProjectError(getErrorText(error));
});
}
if (!conversationId) return;
const keepaliveTask: WorkbenchKeepaliveTask = {
taskId,
conversationId,
assistantMessageId,
concurrencySlotId: claimedGenerationSlotId,
mode: activeMode,
modelLabel: activeModel,
specs: getCurrentSpecs(),
referenceCount: referenceItems.length,
progress: 30,
statusLabel: "Task submitted, generating...",
startedAt: Date.now(),
};
const taskSpecs = buildAssistantResult(activeMode, activeModel, getCurrentSpecs(), referenceItems.length);
keepaliveStarted = true;
upsertKeepaliveTask(keepaliveTask);
releaseClaimedGenerationSlot = null;
await patchConversationMessage(conversationId, assistantMessageId, {
body: "Task submitted, generating...",
status: "thinking",
taskId,
conversationId,
taskProgress: 30,
taskStatusLabel: "Task submitted, generating...",
resultType: activeMode,
result: taskSpecs,
});
runKeepalivePoll(keepaliveTask);
} else {
let streamedText = "";
setGenerationProgress(36);
setGenerationStatus("正在回复");
updateAssistantMessage(assistantMessageId, {
body: "我在整理,马上说清楚。",
status: "thinking",
taskStatusLabel: "正在整理思路",
});
const historyMessages = buildNaturalChatHistoryMessages(baseMessages);
await aiGenerationClient.streamChat(
{
model: activeModelValue,
messages: [
{ role: "system", content: CHAT_NATURAL_SYSTEM_PROMPT },
...historyMessages,
{ role: "system", content: CHAT_TURN_STYLE_REMINDER },
{ role: "user", content: trimmedPrompt },
],
temperature: 0.82,
},
(chunk) => {
if (abortController.signal.aborted) return;
streamedText += chunk;
setGenerationProgress((current) => Math.min(95, current + 1));
updateAssistantMessage(assistantMessageId, {
body: streamedText || "我在整理,马上说清楚。",
status: "thinking",
taskStatusLabel: "正在回复",
});
},
abortController.signal,
);
if (abortController.signal.aborted) return;
setGenerationProgress(100);
setGenerationStatus("回复完成");
const completedMessages = updateAssistantMessage(assistantMessageId, {
body: streamedText.trim() || "收到。你可以继续补充目标,我会顺着当前上下文往下拆。",
status: "completed",
});
if (!conversationId) {
const conv = await conversationClient.create(
trimmedPrompt.slice(0, 20) || "新对话",
activeMode,
completedMessages.map((message) => ({ ...message, conversationId: undefined })),
);
conversationId = conv.id;
skipConversationAutoSelectRef.current = false;
setActiveConversationId(conv.id);
activeConversationIdRef.current = conv.id;
setConversations((prev) => [conv, ...prev.filter((item) => item.id !== conv.id)]);
const boundMessages = completedMessages.map((message) => ({ ...message, conversationId: conv.id }));
messagesRef.current = boundMessages;
setMessages(boundMessages);
rememberConversationMessages(conv.id, boundMessages);
await conversationClient.update(conv.id, { messages: getPersistableMessages(boundMessages) as never[] });
void loadConversations();
} else {
persistConversationAfterResult(conversationId, messagesRef.current);
}
}
} catch (error) {
if (abortController.signal.aborted) {
setGenerationStatus("已停止");
updateAssistantMessage(assistantMessageId, {
body: "已停止当前生成。",
status: "failed",
});
return;
}
setGenerationStatus("创建失败");
if (isInsufficientBalance(error)) setShowRechargeModal(true);
updateAssistantMessage(assistantMessageId, {
body: isAuthFailure(error) ? "登录状态已失效,请重新登录后再继续。" : error instanceof Error ? error.message : "任务创建失败,请稍后重试。",
status: "failed",
});
} finally {
if (generationAbortRef.current === abortController) {
generationAbortRef.current = null;
}
releaseClaimedGenerationSlot?.();
if (keepaliveStarted) syncActiveGenerationUi(conversationId);
else setIsGenerating(false);
}
};
const handleStopAction = () => {
const conversationId = activeConversationIdRef.current;
const tasksToStop = Object.values(keepaliveTasksRef.current).filter(
(task) => task.conversationId === conversationId,
);
tasksToStop.forEach((task) => {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
removeKeepaliveTask(task.taskId);
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
void patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: "Stopped on this page.",
status: "failed",
taskStatusLabel: "Stopped",
});
});
if (tasksToStop.length === 0) {
generationAbortRef.current?.abort();
}
setIsGenerating(false);
setGenerationStatus("Stopped");
setGenerationProgress(0);
setMessages((current) =>
current.map((message) =>
message.status === "thinking" && (!conversationId || message.conversationId === conversationId)
? {
...message,
body: "Stopped on this page.",
status: "failed",
taskStatusLabel: "Stopped",
}
: message,
),
);
};
const handleStopSingleTask = (messageId: string) => {
const task = Object.values(keepaliveTasksRef.current).find(
(t) => t.assistantMessageId === messageId,
);
if (task) {
taskAbortControllersRef.current.get(task.taskId)?.abort();
taskAbortControllersRef.current.delete(task.taskId);
removeKeepaliveTask(task.taskId);
aiGenerationClient.cancelTask(task.taskId).catch(() => {});
void patchConversationMessage(task.conversationId, task.assistantMessageId, {
body: "已终止",
status: "failed",
taskStatusLabel: "已终止",
});
} else {
generationAbortRef.current?.abort();
}
setMessages((current) =>
current.map((msg) =>
msg.id === messageId
? { ...msg, body: "已终止", status: "failed" as const, taskStatusLabel: "已终止" }
: msg,
),
);
const remainingTasks = Object.values(keepaliveTasksRef.current).filter(
(t) => t.assistantMessageId !== messageId,
);
if (remainingTasks.length === 0) {
setIsGenerating(false);
setGenerationStatus("准备就绪");
setGenerationProgress(0);
}
};
const handleRegenerate = (message: ChatMessage) => {
const userMsg = messages.find(
(m, i) => m.role === "user" && messages[i + 1]?.id === message.id,
);
if (userMsg) {
setInputValue(userMsg.body);
void handleSendAction(userMsg.body);
}
};
const handleSuperResolveVideo = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType !== "video") {
setProjectError("仅支持对视频结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError("当前任务数已达上限(3个),请等待任务完成后再试");
return;
}
if (!isAuthenticated) {
setProjectError("请先登录后再进行视频超分");
return;
}
const conversationId = activeConversationIdRef.current || message.conversationId || activeConversationId;
if (!conversationId) {
setProjectError("请先在当前对话中保存记录后再进行视频超分");
return;
}
const assistantMessageId = message.id;
const previousResultUrl = message.resultUrl;
const previousStatus = message.status || "completed";
const previousTaskProgress = message.taskProgress;
const previousTaskId = message.taskId;
const previousResultOriginalUrl = message.resultOriginalUrl;
const previousResultOssKey = message.resultOssKey;
const previousResultMimeType = message.resultMimeType;
const isAdmin = getCachedRole() === "admin";
setIsGenerating(true);
setGenerationStatus("正在提交超分任务");
setGenerationProgress(12);
if (isAdmin) console.log("[超分] 开始处理", { resultUrl: previousResultUrl, resultType: message.resultType, ossKey: message.resultOssKey, taskId: previousTaskId });
let sourceResult: PersistedWorkbenchResultAsset;
try {
sourceResult = await persistWorkbenchResultAsset({
title: message.result?.title || "生成视频",
sourceUrl: previousResultUrl,
resultType: "video",
taskId: previousResultOssKey ? undefined : previousTaskId,
originalUrl: previousResultOriginalUrl,
existingOssKey: message.resultOssKey,
mimeType: previousResultMimeType,
});
if (isAdmin) console.log("[超分] 资源转存成功", sourceResult);
} catch (error) {
if (isAdmin) console.error("[超分] 资源转存失败", error);
setProjectError(error instanceof Error ? error.message : "视频资源转存失败,请重新生成后再试");
setIsGenerating(false);
setGenerationProgress(0);
setGenerationStatus("超分失败");
return;
}
await patchConversationMessage(conversationId, assistantMessageId, {
body: "视频超分任务已提交,正在增强画质...",
status: "thinking",
conversationId,
taskProgress: 12,
taskStatusLabel: "正在提交超分任务",
resultType: "video",
resultUrl: sourceResult.url,
resultOriginalUrl: sourceResult.originalUrl,
resultOssKey: sourceResult.ossKey,
resultMimeType: sourceResult.mimeType,
});
try {
const superResolveVideoUrl = sourceResult.url;
const result = await aiGenerationClient.createVideoSuperResolveTask({
conversationId,
videoUrl: superResolveVideoUrl,
});
if (isAdmin) console.log("[超分] API 提交成功", { taskId: result.taskId, videoUrl: superResolveVideoUrl });
const keepaliveTask: WorkbenchKeepaliveTask = {
taskId: result.taskId,
conversationId,
assistantMessageId,
operation: "video-super-resolution",
mode: "video",
modelLabel: "视频超分",
specs: ["2x", "MP4"],
referenceCount: 0,
progress: 18,
statusLabel: "超分处理中...",
startedAt: Date.now(),
};
upsertKeepaliveTask(keepaliveTask);
await patchConversationMessage(conversationId, assistantMessageId, {
taskId: result.taskId,
status: "thinking",
taskProgress: 18,
taskStatusLabel: "超分处理中...",
resultUrl: sourceResult.url,
resultOriginalUrl: sourceResult.originalUrl,
resultOssKey: sourceResult.ossKey,
resultMimeType: sourceResult.mimeType,
});
runKeepalivePoll(keepaliveTask);
} catch (error) {
if (isAdmin) console.error("[超分] API 提交失败", error, { videoUrl: sourceResult.url, conversationId });
if (isInsufficientBalance(error)) setShowRechargeModal(true);
await patchConversationMessage(conversationId, assistantMessageId, {
body: error instanceof Error ? error.message : "视频超分任务提交失败",
status: previousStatus,
taskId: previousTaskId,
taskProgress: previousTaskProgress,
taskStatusLabel: "超分失败",
resultUrl: sourceResult.url,
resultType: "video",
resultOriginalUrl: sourceResult.originalUrl,
resultOssKey: sourceResult.ossKey,
resultMimeType: sourceResult.mimeType,
});
setIsGenerating(false);
setGenerationProgress(0);
setGenerationStatus("超分失败");
}
};
const handleSuperResolveImage = async (message: ChatMessage) => {
if (!message.resultUrl || message.resultType === "video") {
setProjectError("仅支持对图片结果进行超分");
return;
}
if (getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3) {
setProjectError("当前任务数已达上限(3个),请等待任务完成后再试");
return;
}
if (!isAuthenticated) {
setProjectError("请先登录后再进行图片超分");
return;
}
const conversationId = activeConversationIdRef.current || message.conversationId || activeConversationId;
if (!conversationId) {
setProjectError("请先在当前对话中保存记录后再进行图片超分");
return;
}
const assistantMessageId = message.id;
const previousResultUrl = message.resultUrl;
const previousStatus = message.status || "completed";
const previousTaskProgress = message.taskProgress;
const previousTaskId = message.taskId;
const isAdmin = getCachedRole() === "admin";
setIsGenerating(true);
setGenerationStatus("正在提交图片超分任务");
setGenerationProgress(12);
const imageUrl = message.resultOriginalUrl || message.resultUrl;
if (isAdmin) console.log("[图片超分] 开始处理", { imageUrl });
await patchConversationMessage(conversationId, assistantMessageId, {
body: "图片超分任务已提交,正在增强画质...",
status: "thinking",
conversationId,
taskProgress: 12,
taskStatusLabel: "正在提交超分任务",
resultType: "image",
resultUrl: previousResultUrl,
});
try {
const result = await aiGenerationClient.createImageSuperResolveTask({
conversationId,
imageUrl,
scale: 2,
});
if (isAdmin) console.log("[图片超分] API 提交成功", { taskId: result.taskId });
const keepaliveTask: WorkbenchKeepaliveTask = {
taskId: result.taskId,
conversationId,
assistantMessageId,
operation: "video-super-resolution",
mode: "image",
modelLabel: "图片超分",
specs: ["2x"],
referenceCount: 0,
progress: 18,
statusLabel: "超分处理中...",
startedAt: Date.now(),
};
upsertKeepaliveTask(keepaliveTask);
await patchConversationMessage(conversationId, assistantMessageId, {
taskId: result.taskId,
status: "thinking",
taskProgress: 18,
taskStatusLabel: "超分处理中...",
resultUrl: previousResultUrl,
resultType: "image",
});
runKeepalivePoll(keepaliveTask);
} catch (error) {
if (isAdmin) console.error("[图片超分] API 提交失败", error);
if (isInsufficientBalance(error)) setShowRechargeModal(true);
setProjectError(error instanceof Error ? error.message : "图片超分提交失败");
await patchConversationMessage(conversationId, assistantMessageId, {
body: "图片超分失败",
status: previousStatus,
taskId: previousTaskId,
taskProgress: previousTaskProgress,
taskStatusLabel: "超分失败",
resultUrl: previousResultUrl,
resultType: "image",
});
setIsGenerating(false);
setGenerationProgress(0);
setGenerationStatus("超分失败");
}
};
const handlePromptKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (promptMentionOpen && promptMentionOptions.length > 0) {
if (event.key === "ArrowDown") {
event.preventDefault();
setMentionActiveIndex((current) => (current + 1) % promptMentionOptions.length);
return;
}
if (event.key === "ArrowUp") {
event.preventDefault();
setMentionActiveIndex((current) => (current - 1 + promptMentionOptions.length) % promptMentionOptions.length);
return;
}
if (event.key === "Enter" || event.key === "Tab") {
event.preventDefault();
insertPromptMention(promptMentionOptions[mentionActiveIndex]?.token || promptMentionOptions[0].token);
return;
}
}
const { selectionStart, selectionEnd } = event.currentTarget;
const selectionMin = Math.min(selectionStart, selectionEnd);
const selectionMax = Math.max(selectionStart, selectionEnd);
const selectedMentionRanges = promptMentionTokenRanges.filter(
(range) => selectionMin < range.end && selectionMax > range.start,
);
const getDeletionRange = () => {
if (selectionStart !== selectionEnd) {
if (selectedMentionRanges.length === 0) return null;
return {
start: selectedMentionRanges.reduce((min, range) => Math.min(min, range.start), selectionMin),
end: selectedMentionRanges.reduce((max, range) => Math.max(max, range.end), selectionMax),
};
}
if (event.key === "Backspace") {
return (
promptMentionTokenRanges.find(
(range) => selectionStart === range.end || (selectionStart > range.start && selectionStart < range.end),
) || null
);
}
if (event.key === "Delete") {
return (
promptMentionTokenRanges.find(
(range) => selectionStart === range.start || (selectionStart > range.start && selectionStart < range.end),
) || null
);
}
return null;
};
const deletionRange = event.key === "Backspace" || event.key === "Delete" ? getDeletionRange() : null;
if (deletionRange) {
event.preventDefault();
let start = deletionRange.start;
let end = deletionRange.end;
if (start > 0 && inputValue[start - 1] === " ") {
start -= 1;
} else if (end < inputValue.length && inputValue[end] === " ") {
end += 1;
}
const nextValue = removePromptTextRange(inputValue, start, end);
const nextCursor = Math.min(start, nextValue.length);
setInputValue(nextValue);
setCursorIndex(nextCursor);
setPromptSelectionRange({ start: nextCursor, end: nextCursor });
window.requestAnimationFrame(() => {
textareaRef.current?.setSelectionRange(nextCursor, nextCursor);
});
return;
}
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
void handleSendAction();
}
};
const sendDisabled = !inputValue.trim() || (activeMode !== "chat" && getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id)) >= 3);
const suggestedPrompts = [
{ text: "画一个赛博朋克风格的城市夜景", mode: "image" as WorkbenchMode },
{ text: "生成一段雨中漫步的电影镜头", mode: "video" as WorkbenchMode },
{ text: "帮我设计一个科幻短片的分镜脚本", mode: "chat" as WorkbenchMode },
{ text: "画一组动漫角色定妆照", mode: "image" as WorkbenchMode },
];
const handleSuggestedPrompt = (prompt: string, mode: WorkbenchMode) => {
setActiveMode(mode);
setInputValue(prompt);
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleUsePromptCase = (item: PromptCaseViewModel) => {
setActiveMode("image");
setInputValue(item.prompt);
setSelectedPromptCase(null);
window.requestAnimationFrame(() => textareaRef.current?.focus());
};
const handleCopyPromptCase = async (item: PromptCaseViewModel) => {
await navigator.clipboard?.writeText(item.prompt);
};
const renderConversationSidebar = () => (
<ProjectSidebar
projects={conversationRecords}
activeId={activeConversationId ? String(activeConversationId) : null}
collapsed={sidebarCollapsed}
filterMode={activeMode}
loading={false}
error={projectError}
onToggle={() => setSidebarCollapsed((v) => (hasSidebarRecords ? !v : true))}
onSelect={handleSelectProject}
onRefresh={handleRefreshProject}
onRename={handleRenameProject}
onDelete={requestDeleteProject}
/>
);
const renderDeleteDialog = () =>
deleteDialog ? (
<div className="workbench-delete-modal" role="dialog" aria-modal="true" aria-labelledby="workbench-delete-title">
<button
type="button"
className="workbench-delete-modal__backdrop"
aria-label="Cancel delete"
onClick={() => {
if (!deleteSubmitting) setDeleteDialog(null);
}}
/>
<div className="workbench-delete-modal__panel">
<div className="workbench-delete-modal__icon">
<DeleteOutlined />
</div>
<div className="workbench-delete-modal__copy">
<span></span>
<h2 id="workbench-delete-title">{deleteDialog.title}</h2>
<p>线</p>
</div>
<div className="workbench-delete-modal__actions">
<button type="button" onClick={() => setDeleteDialog(null)} disabled={deleteSubmitting}>
</button>
<button type="button" className="is-danger" onClick={() => void confirmDeleteProject()} disabled={deleteSubmitting}>
{deleteSubmitting ? "删除中..." : "确认删除"}
</button>
</div>
</div>
</div>
) : null;
const renderMentionPanel = () => {
if (!promptMentionOpen) return null;
const refCount = referenceItems.length;
const assetCount = promptMentionOptions.length - refCount;
const hasBoth = refCount > 0 && assetCount > 0;
return (
<div
className={`ai-chat-mention-panel ai-chat-mention-panel--${mentionPanelPlacement}`}
role="listbox"
aria-label="选择参考内容"
>
<div className="ai-chat-mention-header"></div>
{promptMentionOptions.length > 0 ? (
<div className="ai-chat-mention-list">
{promptMentionOptions.flatMap((item, index) => {
const nodes: React.ReactNode[] = [];
if (hasBoth && index === 0) nodes.push(<div key="section-upload" className="ai-chat-mention-section"></div>);
if (hasBoth && index === refCount) nodes.push(<div key="section-assets" className="ai-chat-mention-section"></div>);
nodes.push(
<button
key={item.id}
type="button"
role="option"
aria-selected={index === mentionActiveIndex}
className={`ai-chat-mention-item${index === mentionActiveIndex ? " is-active" : ""}`}
onMouseEnter={() => setMentionActiveIndex(index)}
onClick={() => insertPromptMention(item.token)}
>
<span className="ai-chat-mention-thumb">
<ReferencePreview item={item} />
</span>
<span className="ai-chat-mention-label">{item.name}</span>
<span className="ai-chat-mention-token">{item.token}</span>
</button>,
);
return nodes;
})}
</div>
) : (
<div className="ai-chat-mention-empty"> @ </div>
)}
</div>
);
};
const renderComposerReferences = (disabled = false) => (
<div
ref={referenceRefsRef}
className={`wb-composer__refs${referenceItems.length > 0 ? " has-items" : ""}${referencePreviewOpen ? " is-open" : ""}`}
>
<button
type="button"
className="wb-composer__ref-upload"
onClick={handleReferenceUploadClick}
disabled={disabled}
aria-label={`上传${referenceUploadLabel}`}
aria-expanded={referenceItems.length > 0 ? referencePreviewOpen : undefined}
aria-controls={referenceItems.length > 0 ? "workbench-reference-stack" : undefined}
>
<PlusOutlined />
<span className="wb-composer__ref-label">{referenceButtonLabel}</span>
{referenceItems.length > 0 ? <span className="wb-composer__ref-count">{referenceItems.length}/{referenceLimit}</span> : null}
</button>
{referenceItems.length > 0 && referencePreviewOpen ? (
<div id="workbench-reference-stack" className="wb-composer__ref-stack" aria-label="已上传参考内容">
{referenceItems.map((item) => (
<div
key={item.id}
className="wb-composer__ref-card"
title={`${item.token} ${item.name}`}
>
<button
type="button"
className="wb-composer__ref-preview"
disabled={disabled}
aria-label={`${item.token} ${item.name}`}
onClick={() => insertPromptMention(item.token)}
>
<ReferencePreview item={item} label={getReferenceKindLabel(item.kind)} />
</button>
<button
type="button"
className="wb-composer__ref-remove"
aria-label={`移除${item.name}`}
disabled={disabled}
onClick={() => removeReferenceItem(item.id)}
>
<CloseOutlined />
</button>
</div>
))}
{!disabled && referenceItems.length < referenceLimit ? (
<button
type="button"
className="wb-composer__ref-add-more"
onClick={handleReferenceAddMore}
aria-label={`继续上传${referenceUploadLabel}`}
>
<PlusOutlined />
</button>
) : null}
</div>
) : null}
</div>
);
const renderComposerToolbar = (disabled = false, showStop = false) => (
<div className="wb-composer__toolbar">
<div className="wb-composer__toolbar-left">
<SelectChip
chipId="studio-mode"
value={activeMode}
options={MODE_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "studio-mode"}
onToggle={() => toggleToolbarMenu("studio-mode")}
onClose={closeToolbarMenus}
onChange={(value) => handleModeChange(value as WorkbenchMode)}
ariaLabel="工作台模式"
direction={dropdownDirection}
/>
{activeMode === "image" && (
<>
<SelectChip
chipId="image-model"
value={imageModel}
options={imageModelOptions}
disabled={disabled}
isOpen={toolbarMenuId === "image-model"}
onToggle={() => toggleToolbarMenu("image-model")}
onClose={closeToolbarMenus}
onChange={setImageModel}
direction={dropdownDirection}
/>
<CompoundSelectChip
chipId="image-settings"
summary={imageSettingsSummary}
groups={imageSettingGroups}
disabled={disabled}
isOpen={toolbarMenuId === "image-settings"}
onToggle={() => toggleToolbarMenu("image-settings")}
direction={dropdownDirection}
/>
<SelectChip
chipId="image-grid-mode"
value={imageGridMode}
options={GRID_MODE_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "image-grid-mode"}
onToggle={() => toggleToolbarMenu("image-grid-mode")}
onClose={closeToolbarMenus}
onChange={setImageGridMode}
direction={dropdownDirection}
/>
</>
)}
{activeMode === "video" && (
<>
<SelectChip
chipId="video-model"
value={videoModel}
options={videoModelOptions}
disabled={disabled}
isOpen={toolbarMenuId === "video-model"}
onToggle={() => toggleToolbarMenu("video-model")}
onClose={closeToolbarMenus}
onChange={setVideoModel}
direction={dropdownDirection}
/>
<SelectChip
chipId="video-mode"
value={videoFrameMode}
options={VIDEO_FRAME_OPTIONS}
disabled={disabled}
isOpen={toolbarMenuId === "video-mode"}
onToggle={() => toggleToolbarMenu("video-mode")}
onClose={closeToolbarMenus}
onChange={setVideoFrameMode}
direction={dropdownDirection}
/>
<CompoundSelectChip
chipId="video-ratio"
summary={videoRatio}
groups={videoRatioGroups}
disabled={disabled}
isOpen={toolbarMenuId === "video-ratio"}
onToggle={() => toggleToolbarMenu("video-ratio")}
direction={dropdownDirection}
/>
<InlineOptionChip
chipId="video-duration"
value={videoDuration}
options={VIDEO_DURATION_OPTIONS}
icon={<ClockCircleOutlined />}
disabled={disabled}
isOpen={toolbarMenuId === "video-duration"}
onToggle={() => toggleToolbarMenu("video-duration")}
onClose={closeToolbarMenus}
onChange={setVideoDuration}
direction={dropdownDirection}
/>
<InlineOptionChip
chipId="video-quality"
value={videoQuality}
options={videoQualityOptions}
icon={<SettingOutlined />}
disabled={disabled}
isOpen={toolbarMenuId === "video-quality"}
onToggle={() => toggleToolbarMenu("video-quality")}
onClose={closeToolbarMenus}
onChange={setVideoQuality}
direction={dropdownDirection}
/>
</>
)}
</div>
<div className="wb-composer__toolbar-right">
<button
type="button"
className={`wb-composer__send-primary${isGenerating ? " is-loading" : ""}`}
disabled={sendDisabled || isGenerating}
onClick={() => {
if (getCachedRole() === "admin") console.log("[ai/workbench-send-click]", {
mode: activeMode,
model: activeModelValue,
inputLength: inputValue.trim().length,
isAuthenticated,
sendDisabled,
isGenerating,
});
void handleSendAction();
}}
data-smoke-id="ai-chat-send"
>
{isGenerating ? <LoadingOutlined /> : <SendOutlined />}
</button>
</div>
</div>
);
// ─── LAUNCH STATE (homepage) ───
const renderMessagePreviewOverlay = () =>
messagePreviewAttachment?.previewUrl ? (
<div className="ai-chat-media-preview" role="dialog" aria-modal="true" onClick={() => setMessagePreviewAttachment(null)}>
<div
className={`ai-chat-media-preview__panel${messagePreviewAttachment.kind === "video" ? " is-video" : ""}`}
onClick={(event) => event.stopPropagation()}
>
<div className="ai-chat-media-preview__head">
<span>{messagePreviewAttachment.name}</span>
<button type="button" onClick={() => setMessagePreviewAttachment(null)} aria-label="Close preview">
<CloseOutlined />
</button>
</div>
<div className="ai-chat-media-preview__body">
{messagePreviewAttachment.kind === "video" ? (
<ImmersiveVideoPlayer
src={messagePreviewAttachment.previewUrl}
title={messagePreviewAttachment.name}
/>
) : (
<img src={messagePreviewAttachment.previewUrl} alt={messagePreviewAttachment.name} />
)}
</div>
</div>
</div>
) : null;
const renderPromptCaseOverlay = () =>
selectedPromptCase ? (
<div className="wb-prompt-case-modal" role="dialog" aria-modal="true" aria-labelledby="wb-prompt-case-title">
<button
type="button"
className="wb-prompt-case-modal__backdrop"
aria-label="关闭案例详情"
onClick={() => setSelectedPromptCase(null)}
/>
<section className="wb-prompt-case-modal__panel">
<div className="wb-prompt-case-modal__media">
<img src={selectedPromptCase.imageUrl} alt={selectedPromptCase.title} />
</div>
<aside className="wb-prompt-case-modal__sidebar">
<button
type="button"
className="wb-prompt-case-modal__close"
aria-label="关闭案例详情"
onClick={() => setSelectedPromptCase(null)}
>
<CloseOutlined />
</button>
<div className="wb-prompt-case-author">
<span>{selectedPromptCase.author.slice(0, 1).toUpperCase()}</span>
<div>
<strong>{selectedPromptCase.author}</strong>
<em>{selectedPromptCase.category}</em>
</div>
</div>
<div className="wb-prompt-case-meta">
<h2 id="wb-prompt-case-title">{selectedPromptCase.title}</h2>
<p>{selectedPromptCase.summary}</p>
<span>{selectedPromptCase.ratio} · </span>
</div>
<div className="wb-prompt-case-prompt">
<span></span>
<p>{selectedPromptCase.prompt}</p>
</div>
<div className="wb-prompt-case-actions">
<button type="button" onClick={() => handleUsePromptCase(selectedPromptCase)}>
<PictureOutlined />
</button>
<button type="button" onClick={() => void handleCopyPromptCase(selectedPromptCase)}>
<CopyOutlined />
</button>
</div>
</aside>
</section>
</div>
) : null;
if (!hasActivatedWorkspace) {
return (
<section
className={`ai-workbench-page is-launch mode-${activeMode} page-motion`}
style={themeVars}
>
<input ref={referenceInputRef} type="file" accept={getReferenceAccept(activeMode, videoFrameMode)} multiple hidden onChange={handleReferenceUpload} />
<div className="ai-workbench-shell">
<div className="ai-workbench-main">
<div className="wb-home">
<div className="wb-home__glow" />
<div className="wb-home__hero">
<h1 className="wb-home__title"></h1>
</div>
<div className="wb-home__composer" ref={toolbarRef}>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
<div className="wb-composer__main">
<textarea
ref={textareaRef}
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
placeholder={composerPlaceholder}
value={inputValue}
onChange={handlePromptChange}
onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange}
onClick={handlePromptSelectionChange}
onMouseUp={handlePromptSelectionChange}
onKeyDown={handlePromptKeyDown}
onScroll={handlePromptScroll}
data-smoke-id="ai-workbench-prompt"
/>
{showPromptPreview ? (
<PromptPreviewLayer text={inputValue} items={promptMentionOptions} onTokenPointerDown={focusPromptAt} />
) : null}
{renderMentionPanel()}
</div>
</div>
{renderComposerToolbar(false, false)}
</div>
</div>
<div className="wb-home__suggestions">
{suggestedPrompts.map((item) => (
<button key={item.text} type="button" className="wb-suggestion-chip" onClick={() => handleSuggestedPrompt(item.text, item.mode)}>
<span className="wb-suggestion-chip__icon">{MODE_ICONS[item.mode]}</span>
<span>{item.text}</span>
</button>
))}
</div>
<section className="wb-prompt-cases" aria-label="图片提示词案例">
<div className="wb-showcase__header">
<h2></h2>
</div>
<div className="wb-prompt-cases__grid">
{promptCaseDisplayItems.map((item, index) => {
const measuredRatio = promptCaseMeasuredRatios[item.id];
return (
<button
key={item.id}
type="button"
className={getPromptCaseCardClassName(item, index, measuredRatio)}
onClick={() => setSelectedPromptCase(item)}
>
<img
src={item.imageUrl}
alt={item.title}
loading="lazy"
onLoad={(event) => handlePromptCaseImageLoad(item.id, event)}
/>
<div>
<strong>{item.title}</strong>
<em>{item.author}</em>
</div>
</button>
);
})}
</div>
</section>
</div>
</div>
</div>
{renderConversationSidebar()}
{renderMessagePreviewOverlay()}
{renderPromptCaseOverlay()}
{renderDeleteDialog()}
</section>
);
}
// ─── ACTIVE STATE (conversation) ───
return (
<section
className={`ai-workbench-page is-active mode-${activeMode} page-motion`}
style={themeVars}
>
<input ref={referenceInputRef} type="file" accept={getReferenceAccept(activeMode, videoFrameMode)} multiple hidden onChange={handleReferenceUpload} />
<div className="ai-workbench-shell">
<div className="ai-workbench-main">
<main className="ai-workbench-content-scroll">
<section className="ai-workbench-thread-shell">
<div className="ai-chat-main-panel">
<div className="ai-chat-messages-surface" ref={messagesSurfaceRef}>
<div className="ai-chat-message-list">
{projectError && (
<div className="conversation-sidebar__empty">
<span>Server data failed to load: {projectError}</span>
</div>
)}
{!projectError && messages.length === 0 && (
<div className="conversation-sidebar__empty">
<span></span>
</div>
)}
{messages.map((message) => (
<article key={message.id} className={`ai-chat-message-row chat-message-enter${message.role === "user" ? " is-user" : ""}`}>
<div className={`ai-chat-avatar${message.role === "user" ? " ai-chat-avatar--user" : ""}`}>
{message.role === "user" ? "我" : "AI"}
</div>
<div className="ai-chat-message-stack">
<div className="ai-chat-message-author">
<span>{message.author}</span>
<span>{message.createdAt}</span>
</div>
<div className={`ai-chat-message-bubble${message.role === "user" ? " ai-chat-message-bubble--user" : " ai-chat-message-bubble--assistant"}${message.status === "thinking" ? " is-thinking" : ""}`}>
{message.role === "user" && message.attachments && message.attachments.length > 0 && (
<div className="ai-chat-attachment-row">
{message.attachments.map((item) => (
<ChatAttachmentPreview key={`${message.id}-${item.token}`} item={item} onOpen={setMessagePreviewAttachment} />
))}
</div>
)}
{!(message.role === "assistant" && message.resultUrl && (message.mode === "image" || message.mode === "video")) && (
message.role === "user" ? (
<p className="ai-chat-message-prompt">{message.body}</p>
) : (
<MarkdownMessage text={message.body} />
)
)}
{message.role !== "user" && message.attachments && message.attachments.length > 0 && (
<div className="ai-chat-attachment-row">
{message.attachments.map((item) => (
<ChatAttachmentPreview key={`${message.id}-${item.token}`} item={item} onOpen={setMessagePreviewAttachment} />
))}
</div>
)}
{message.status === "failed" && message.role === "assistant" && (message.mode === "image" || message.mode === "video") && (
<div className="ai-chat-failed-actions">
<button type="button" className="ai-chat-failed-actions__retry" onClick={() => handleRegenerate(message)}>
<ReloadOutlined />
</button>
<button type="button" className="ai-chat-failed-actions__switch" onClick={() => { setToolbarMenuId(message.mode === "video" ? "video-model" : "image-model"); scrollMessagesSurface("bottom"); }}>
<AppstoreOutlined />
</button>
</div>
)}
{message.status === "thinking" && !message.resultUrl && (message.mode === "image" || message.mode === "video") && (
<GenerationPendingCard message={message} onStop={() => handleStopSingleTask(message.id)} />
)}
{message.status === "thinking" && message.mode === "chat" && (
<div className="ai-chat-progress">
<span>{message.taskStatusLabel || generationStatus}</span>
</div>
)}
{(message.resultUrl || (message.result && message.status !== "thinking")) && (
<ResultCard
message={message}
onRegenerate={handleRegenerate}
onOpenMedia={setMessagePreviewAttachment}
onSuperResolveVideo={handleSuperResolveVideo}
onSuperResolveImage={handleSuperResolveImage}
onOpenResultInCanvas={onOpenResultInCanvas}
downloadFilenameBase={downloadFilenameBase}
/>
)}
</div>
</div>
</article>
))}
<div ref={messagesEndRef} />
</div>
</div>
</div>
</section>
<section className={`wb-composer${composerHidden ? " is-hidden" : ""}`} ref={toolbarRef}>
<div className="wb-composer__content">
<div className="wb-composer__input-row">
{renderComposerReferences(false)}
<div className="wb-composer__main">
<textarea
ref={textareaRef}
className={`wb-composer__textarea${showPromptPreview ? " wb-composer__textarea--overlay-mode" : ""}`}
placeholder={composerPlaceholder}
value={inputValue}
disabled={false}
onChange={handlePromptChange}
onSelect={handlePromptSelectionChange}
onKeyUp={handlePromptSelectionChange}
onClick={handlePromptSelectionChange}
onMouseUp={handlePromptSelectionChange}
onKeyDown={handlePromptKeyDown}
onScroll={handlePromptScroll}
/>
{showPromptPreview ? (
<PromptPreviewLayer text={inputValue} items={promptMentionOptions} onTokenPointerDown={focusPromptAt} />
) : null}
{renderMentionPanel()}
</div>
</div>
{renderComposerToolbar(false, isGenerating)}
</div>
</section>
<div className="wb-chat-scroll-actions" aria-label="聊天滚动">
<button
type="button"
className="wb-chat-scroll-actions__button"
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
>
<ArrowUpOutlined />
</button>
<button
type="button"
className="wb-chat-scroll-actions__button"
title="到达聊天底部"
aria-label="到达聊天底部"
onClick={() => scrollMessagesSurface("bottom")}
>
<ArrowDownOutlined />
</button>
</div>
</main>
</div>
{renderConversationSidebar()}
</div>
{renderMessagePreviewOverlay()}
{renderDeleteDialog()}
<RechargeModal open={showRechargeModal} onClose={() => setShowRechargeModal(false)} currentBalance={session?.user?.balanceCents} />
</section>
);
}
export default WorkbenchPage;