Files
omniai-web/src/features/workbench/WorkbenchPage.tsx
T

3117 lines
122 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
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 { useGenerationTasks } from "../../hooks/useGenerationTasks";
2026-06-02 12:38:01 +08:00
import { conversationClient, type ConversationSummary } from "../../api/conversationClient";
import { modelCapabilitiesClient } from "../../api/modelCapabilitiesClient";
import { buildApiUrl, buildAuthHeaders } from "../../api/serverConnection";
2026-06-02 12:38:01 +08:00
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";
2026-06-02 12:38:01 +08:00
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";
2026-06-02 12:38:01 +08:00
interface WorkbenchPageProps {
isAuthenticated: boolean;
session: WebUserSession | null;
onRequireLogin: (input: CreatePreviewTaskInput) => void;
onOpenResultInCanvas?: (payload: import("./workbenchConstants").WorkbenchResultActionPayload) => void;
2026-06-02 12:38:01 +08:00
onRefreshUsage?: () => void;
}
// ─── Component ───────────────────────────────────────────────────────────
2026-06-02 12:38:01 +08:00
// (All types, constants, helpers, and sub-components extracted to sibling modules)
2026-06-02 12:38:01 +08:00
// ─── 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.
2026-06-02 12:38:01 +08:00
const MODE_ICONS: Record<WorkbenchMode, ReactNode> = {
chat: <MessageOutlined />,
image: <PictureOutlined />,
video: <VideoCameraOutlined />,
2026-06-02 12:38:01 +08:00
};
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 genTracker = useGenerationTasks({ sourceView: "workbench" });
2026-06-02 12:38:01 +08:00
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 [isComposerDragging, setIsComposerDragging] = useState(false);
const composerDragCounterRef = useRef(0);
2026-06-02 12:38:01 +08:00
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(() => {
if (!isAuthenticated) return;
2026-06-02 12:38:01 +08:00
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);
});
}, []);
2026-06-02 12:38:01 +08:00
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?.();
if (status.status === "completed") {
import("../../utils/generationNotifier").then((m) =>
m.notifyTaskCompleted(task.mode === "video" ? "视频" : "图片", task.mode as "image" | "video"),
);
}
2026-06-02 12:38:01 +08:00
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]);
2026-06-02 12:38:01 +08:00
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" });
}, []);
2026-06-02 12:38:01 +08:00
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 handleReferenceUploadClick = () => {
if (referenceItems.length > 0) {
setToolbarMenuId(null);
setReferencePreviewOpen((current) => !current);
return;
}
referenceInputRef.current?.click();
};
const handleReferenceAddMore = () => {
setToolbarMenuId(null);
setReferencePreviewOpen(true);
referenceInputRef.current?.click();
};
const processReferenceFiles = async (files: File[]) => {
2026-06-02 12:38:01 +08:00
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 handleReferenceUpload = async (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
event.target.value = "";
await processReferenceFiles(files);
2026-06-02 12:38:01 +08:00
};
const handleComposerDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current += 1;
if (composerDragCounterRef.current === 1) {
setIsComposerDragging(true);
}
}, []);
const handleComposerDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current -= 1;
if (composerDragCounterRef.current <= 0) {
composerDragCounterRef.current = 0;
setIsComposerDragging(false);
}
}, []);
const handleComposerDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleComposerDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
composerDragCounterRef.current = 0;
setIsComposerDragging(false);
const files = Array.from(e.dataTransfer.files);
if (files.length > 0) {
void processReferenceFiles(files);
}
}, [activeMode]);
2026-06-02 12:38:01 +08:00
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;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "image", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
2026-06-02 12:38:01 +08:00
} 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;
genTracker.submitTask({ title: trimmedPrompt.slice(0, 60), type: "video", status: "running", progress: 5, prompt: trimmedPrompt, sourceView: "workbench", taskId });
2026-06-02 12:38:01 +08:00
}
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(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
2026-06-02 12:38:01 +08:00
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(`当前已有 ${getActiveGenerationTaskCount(getGenerationUserKey(session?.user.id))} 个任务进行中(上限3个),请等待任一任务完成后再提交新任务`);
2026-06-02 12:38:01 +08:00
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}
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>
{(item.kind === "image" || item.kind === "video") && item.previewUrl ? (
<span className="wb-composer__ref-zoom" aria-hidden="true">
{item.kind === "video" ? <video src={item.previewUrl} muted playsInline /> : <img src={item.previewUrl} alt="" />}
</span>
) : null}
2026-06-02 12:38:01 +08:00
<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${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
2026-06-02 12:38:01 +08:00
<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>
2026-06-02 12:38:01 +08:00
<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" : ""}`}>
2026-06-02 12:38:01 +08:00
<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" : ""}${isComposerDragging ? " wb-composer--drag-active" : ""}`}
ref={toolbarRef}
onDragEnter={handleComposerDragEnter}
onDragLeave={handleComposerDragLeave}
onDragOver={handleComposerDragOver}
onDrop={handleComposerDrop}
>
2026-06-02 12:38:01 +08:00
<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="聊天滚动">
2026-06-02 12:38:01 +08:00
<button
type="button"
className="wb-chat-scroll-actions__button"
2026-06-02 12:38:01 +08:00
title="返回聊天顶部"
aria-label="返回聊天顶部"
onClick={() => scrollMessagesSurface("top")}
>
<ArrowUpOutlined />
</button>
<button
type="button"
className="wb-chat-scroll-actions__button"
2026-06-02 12:38:01 +08:00
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;