feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程
This commit is contained in:
@@ -538,9 +538,9 @@ export function AgentReasoningFeed({ items, theme }: { items: CloudAgentChatMess
|
||||
|
||||
/**
|
||||
* Agent SSE events contain text chunks. Keep the full text in the message
|
||||
* state, but reveal one code point at a time so a chunk never appears as a
|
||||
* whole block. The loop continues briefly after the stream ends to drain any
|
||||
* text that was buffered by the network.
|
||||
* state, but reveal it in small batches. A fixed one-code-point delay lets a
|
||||
* fast stream build an ever-growing queue, so the batch grows with the
|
||||
* backlog and eventually catches up to the latest text in one render.
|
||||
*/
|
||||
function useTypewriterText(targetText: string, shouldAnimate: boolean) {
|
||||
const targetRef = useRef(targetText);
|
||||
@@ -568,16 +568,29 @@ function useTypewriterText(targetText: string, shouldAnimate: boolean) {
|
||||
}
|
||||
|
||||
const currentCharacters = Array.from(visibleRef.current);
|
||||
if (currentCharacters.length >= targetCharacters.length) {
|
||||
const pendingCharacters = targetCharacters.length - currentCharacters.length;
|
||||
if (pendingCharacters <= 0) {
|
||||
runningRef.current = false;
|
||||
timerRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const nextText = targetCharacters.slice(0, currentCharacters.length + 1).join("");
|
||||
// Normal output remains visibly animated. Once stream chunks start
|
||||
// outrunning the renderer, increase the batch instead of queuing
|
||||
// every code point behind a fixed 16ms delay.
|
||||
const revealCount = pendingCharacters > 384
|
||||
? pendingCharacters
|
||||
: pendingCharacters > 192
|
||||
? 32
|
||||
: pendingCharacters > 96
|
||||
? 16
|
||||
: pendingCharacters > 24
|
||||
? 8
|
||||
: 3;
|
||||
const nextText = targetCharacters.slice(0, currentCharacters.length + revealCount).join("");
|
||||
visibleRef.current = nextText;
|
||||
setVisibleText(nextText);
|
||||
timerRef.current = window.setTimeout(step, 16);
|
||||
timerRef.current = window.setTimeout(step, 12);
|
||||
};
|
||||
|
||||
step();
|
||||
|
||||
@@ -52,6 +52,7 @@ import { getActiveUserScope } from "@/lib/user-scope";
|
||||
import { applyAgentCanvasPatches, refreshCanvasAfterAgent, saveRemoteUserDataNow } from "@/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "@/services/agent-canvas-sync";
|
||||
import { buildSkillMentionReferences, resolveSkillMentions } from "@/services/skill-runtime";
|
||||
import { DEFAULT_CANVAS_AGENT_SKILL_ID, ensureDefaultCanvasAgentSkill } from "@/services/skill-defaults";
|
||||
import { AGENT_SCENE_DEFS, AgentChatComposer, parseCloudAgentFormAnswer, AgentPlanBar, AgentQuestionBar, AgentSceneCapsules, type AgentSceneBucket, type CloudAgentChatMessage } from "./canvas-cloud-agent-chat-ui";
|
||||
import { CanvasAgentSkillLibraryModal } from "./canvas-agent-skill-library-modal";
|
||||
import { useSkillCuration, curationQuery } from "@/components/skills/skill-curation-browser";
|
||||
@@ -485,21 +486,52 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
.then(async (document) => {
|
||||
if (!active) return;
|
||||
const current = document.conversations.find((conversation) => conversation.id === document.activeId) || document.conversations[0];
|
||||
let initialSkillIds = current?.skillIds || [];
|
||||
if (!document.conversations.length) {
|
||||
try {
|
||||
const defaults = await ensureDefaultCanvasAgentSkill();
|
||||
if (!active) return;
|
||||
setSkills(defaults.skills);
|
||||
initialSkillIds = defaults.skills.some((skill) => skill.skillId === DEFAULT_CANVAS_AGENT_SKILL_ID && skill.isAdded)
|
||||
? [DEFAULT_CANVAS_AGENT_SKILL_ID]
|
||||
: [];
|
||||
} catch (cause) {
|
||||
if (active) setMessages((currentMessages) => appendAgentError(currentMessages, "default-skill-error", cause, "默认短剧技能未能安装,已保留空技能选择"));
|
||||
}
|
||||
}
|
||||
setConversations(document.conversations);
|
||||
if (current) {
|
||||
setActiveConversationId(current.id);
|
||||
setMessages(current.messages);
|
||||
setRun(current.run);
|
||||
setPermissionMode(current.permissionMode);
|
||||
setSelectedSkillIds(current.skillIds || []);
|
||||
setSelectedSkillIds(initialSkillIds);
|
||||
if (current.model) setModel(current.model);
|
||||
const pending = await loadCloudAgentPendingSubmission(canvasId, current.id);
|
||||
if (!active) return;
|
||||
pendingSubmission.current = pending;
|
||||
if (pending?.request) setPrompt(pending.displayText || pending.request.prompt);
|
||||
} else {
|
||||
setActiveConversationId(nanoid());
|
||||
const initialConversationId = nanoid();
|
||||
const now = new Date().toISOString();
|
||||
const initialConversation: CloudAgentConversation = {
|
||||
id: initialConversationId,
|
||||
title: "新对话",
|
||||
messages: [],
|
||||
run: null,
|
||||
model: selectedModel || undefined,
|
||||
permissionMode,
|
||||
skillIds: initialSkillIds,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
setActiveConversationId(initialConversationId);
|
||||
setConversations([initialConversation]);
|
||||
setSelectedSkillIds(initialSkillIds);
|
||||
pendingSubmission.current = null;
|
||||
void saveCloudAgentConversations(canvasId, initialConversationId, [initialConversation]).catch((cause) => {
|
||||
if (active) setMessages((currentMessages) => appendAgentError(currentMessages, "initial-conversation-save-error", cause, "新对话初始化保存失败,请重试后再发送"));
|
||||
});
|
||||
}
|
||||
setPendingHydrated(true);
|
||||
})
|
||||
@@ -516,7 +548,16 @@ export function CanvasCloudAgentPanel({ canvasId, domainProjectId, nodeCount, se
|
||||
}, [canvasId, userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyHydrated || (!messages.length && !run)) return;
|
||||
if (!historyHydrated) return;
|
||||
if (!messages.length && !run) {
|
||||
setConversations((current) => {
|
||||
const existing = current.find((conversation) => conversation.id === activeConversationId);
|
||||
if (!existing || existing.skillIds?.join("\0") === selectedSkillIds.join("\0")) return current;
|
||||
const now = new Date().toISOString();
|
||||
return current.map((conversation) => conversation.id === activeConversationId ? { ...conversation, skillIds: selectedSkillIds, updatedAt: now } : conversation);
|
||||
});
|
||||
return;
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
setConversations((current) => {
|
||||
const existing = current.find((conversation) => conversation.id === activeConversationId);
|
||||
|
||||
@@ -18,6 +18,7 @@ import { isCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
import { nodeGenerationPrompt } from "@/lib/canvas/generation-contract";
|
||||
import type { ModelReferenceLimits } from "@/lib/model-selection";
|
||||
import type { Asset } from "@/stores/use-asset-store";
|
||||
import { resourceIdFromStorageKey } from "@/services/api/resources";
|
||||
|
||||
export type CharacterGenerationReference = {
|
||||
nodeId: string;
|
||||
@@ -444,18 +445,28 @@ export function buildNodeResponseMessages(context: NodeGenerationContext): AiTex
|
||||
];
|
||||
}
|
||||
|
||||
export async function hydrateNodeGenerationContext(context: NodeGenerationContext, projectId: string, domainProjectId?: string, mode?: CanvasGenerationMode, includeCharacterVoiceSamples = false, includeCharacterPrompt = true, referenceLimits?: ModelReferenceLimits) {
|
||||
export async function hydrateNodeGenerationContext(
|
||||
context: NodeGenerationContext,
|
||||
projectId: string,
|
||||
domainProjectId?: string,
|
||||
mode?: CanvasGenerationMode,
|
||||
includeCharacterVoiceSamples = false,
|
||||
includeCharacterPrompt = true,
|
||||
referenceLimits?: ModelReferenceLimits,
|
||||
materializeStoredMedia = true,
|
||||
) {
|
||||
const { imageToDataUrl } = await import("@/services/image-storage");
|
||||
let referenceImages = await Promise.all(
|
||||
context.referenceImages.map(async (image) => {
|
||||
if (image.source?.kind === "drawing") return resolveCanvasDrawingReference(projectId, image);
|
||||
if (image.source?.kind === "colorgrade") return resolveCanvasColorGradeReference(image);
|
||||
if (!materializeStoredMedia && resourceIdFromStorageKey(image.storageKey)) return image;
|
||||
return { ...image, dataUrl: await imageToDataUrl(image) };
|
||||
}),
|
||||
);
|
||||
if (!context.characterReferences.length) return { ...context, referenceImages };
|
||||
const { getCharacter } = await import("@/services/api/projects");
|
||||
const { getResource, resourceFileUrl, resourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const { getResource, resourceFileUrl, resourceIdFromStorageKey: getResourceIdFromStorageKey, resourceStorageKey } = await import("@/services/api/resources");
|
||||
const details = await Promise.all(context.characterReferences.map((reference) => getCharacter(reference.assetId)));
|
||||
const remainingBudget = Math.max(0, (referenceLimits?.maxImages ?? 9) - referenceImages.length);
|
||||
const selectedEntries = details.flatMap((detail, index) => {
|
||||
@@ -484,7 +495,9 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
dataUrl: "",
|
||||
storageKey: resourceStorageKey(entry.representation.resourceId),
|
||||
} satisfies ReferenceImage));
|
||||
const hydratedCharacterImages = await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })));
|
||||
const hydratedCharacterImages = materializeStoredMedia
|
||||
? await Promise.all(characterImages.map(async (image) => ({ ...image, dataUrl: await imageToDataUrl(image) })))
|
||||
: characterImages;
|
||||
const primaryByNodeId = new Map<string, ReferenceImage>();
|
||||
selectedEntries.forEach((entry, index) => {
|
||||
const image = hydratedCharacterImages[index];
|
||||
@@ -545,7 +558,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
instructions: [language && `语言与口音:${language}`, voiceAge && `声音年龄感:${voiceAge}`, timbre && `音色气质:${timbre}`, deliveryInstructions].filter(Boolean).join(";"),
|
||||
}];
|
||||
});
|
||||
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => resourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
|
||||
const usedAudioResourceIds = new Set(context.referenceAudios.map((audio) => getResourceIdFromStorageKey(audio.storageKey)).filter(Boolean));
|
||||
const voiceSamples: ResolvedCharacterVoice[] = [];
|
||||
// 视频模型接收声音样本;独立配音任务仍通过 voiceKey 选音色,不能把两种协议混用。
|
||||
if (mode === "video" && includeCharacterVoiceSamples) {
|
||||
@@ -570,7 +583,7 @@ export async function hydrateNodeGenerationContext(context: NodeGenerationContex
|
||||
}));
|
||||
const referenceAudios = [...context.referenceAudios, ...characterVoiceAudios];
|
||||
const voiceBlocks = mode === "video" ? resolvedCharacterVoices.flatMap((voice) => {
|
||||
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => resourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
|
||||
const sampleIndex = voice.sampleResourceId ? referenceAudios.findIndex((audio) => getResourceIdFromStorageKey(audio.storageKey) === voice.sampleResourceId) : -1;
|
||||
if (sampleIndex < 0 && !includeCharacterPrompt) return [];
|
||||
return [[
|
||||
includeCharacterPrompt ? compileResolvedVoicePrompt(voice) : `【角色声音:${voice.characterName}】`,
|
||||
|
||||
@@ -8,18 +8,11 @@ import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize } from "@/lib/image-resolution-tiers";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, type ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { mergedImageCapabilityConfig } from "@/lib/model-selection";
|
||||
import { imagePriceSelector, normalizeImagePriceSelector } from "@/lib/image-pricing";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
|
||||
const qualityOptions = [
|
||||
{ value: "auto", label: "自动" },
|
||||
{ value: "high", label: "高" },
|
||||
{ value: "medium", label: "中" },
|
||||
{ value: "low", label: "低" },
|
||||
{ value: "1k", label: "1K" },
|
||||
{ value: "2k", label: "2K" },
|
||||
{ value: "4k", label: "4K" },
|
||||
];
|
||||
|
||||
export { imageQualityLabel } from "@/lib/image-quality";
|
||||
type AspectOption = { value: string; label: string; width: number; height: number; icon: string; size?: string };
|
||||
|
||||
const aspectOptions: AspectOption[] = [
|
||||
@@ -65,7 +58,7 @@ export function ImageSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
const effectiveMaxCount = Math.min(maxCount, profile.maxOutputs);
|
||||
const count = Math.max(1, Math.min(effectiveMaxCount, Number(normalized.count)));
|
||||
const activeSize = normalized.size;
|
||||
const activeQualityOptions = profile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value });
|
||||
const activeQualityOptions = profile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }));
|
||||
const priceTiers = imageModelPriceTiers(config);
|
||||
|
||||
return (
|
||||
@@ -142,10 +135,6 @@ export function ImageSettingsTheme({ theme, children }: { theme: CanvasTheme; ch
|
||||
);
|
||||
}
|
||||
|
||||
export function imageQualityLabel(value: string) {
|
||||
return ({ auto: "自动", high: "高", medium: "中", low: "低", "1k": "1K", "2k": "2K", "4k": "4K" } as Record<string, string>)[value.toLowerCase()] || value || "默认";
|
||||
}
|
||||
|
||||
function isGrokResolutionQuality(profile: ImageCapabilityConfig) {
|
||||
const values = profile.quality.values.map((item) => item.toLowerCase());
|
||||
return values.some((value) => ["1k", "2k", "4k"].includes(value));
|
||||
@@ -163,11 +152,12 @@ function imageModelPriceTiers(config: AiConfig) {
|
||||
}
|
||||
|
||||
function hasPriceTierForImageSelection(tiers: ReturnType<typeof imageModelPriceTiers>, quality: string, size: string) {
|
||||
if (!tiers.length) return true;
|
||||
return tiers.some((tier) => {
|
||||
const selector = tier.selector || {};
|
||||
return (!selector.quality || selector.quality === "*" || selector.quality === quality.toLowerCase()) && (!selector.size || selector.size === "*" || selector.size === size.toLowerCase());
|
||||
});
|
||||
if (!tiers.length) return true;
|
||||
const requested = imagePriceSelector(quality, size);
|
||||
return tiers.some((tier) => {
|
||||
const selector = normalizeImagePriceSelector(tier.selector || {});
|
||||
return Object.entries(selector).every(([key, value]) => value === "*" || requested[key] === value);
|
||||
});
|
||||
}
|
||||
|
||||
function OptionPill({ selected, disabled = false, theme, onClick, children }: { selected: boolean; disabled?: boolean; theme: CanvasTheme; onClick: () => void; children: ReactNode }) {
|
||||
|
||||
@@ -162,6 +162,36 @@
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 84px), 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
.image-size-preset-option-wrap {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-size-preset-dimensions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: var(--fs-tiny);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.image-size-preset-dimensions label {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-size-preset-dimensions label > span {
|
||||
padding-inline: 2px;
|
||||
}
|
||||
.image-size-preset-dimensions .ant-input-number {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.image-size-preset-dimensions .ant-input-number-input {
|
||||
padding-inline: 5px;
|
||||
font-size: var(--fs-tiny);
|
||||
}
|
||||
.image-size-preset-option,
|
||||
.image-size-default-options button {
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useId, useState } from "react";
|
||||
import { Button, Input } from "antd";
|
||||
import { Button, Input, InputNumber } from "antd";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { Switch } from "@/components/ui/base/switch";
|
||||
import "./image-size-picker.css";
|
||||
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
|
||||
import { validateImageSize } from "@/services/api/image-validation";
|
||||
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
|
||||
|
||||
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
|
||||
@@ -36,6 +37,25 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const editDimensions = (tier: ImageResolutionTier, ratio: string, field: "width" | "height", rawValue: number | null) => {
|
||||
if (disabled) return;
|
||||
const value = rawValue == null ? NaN : Math.round(rawValue);
|
||||
const selected = presets.find((preset) => preset.tier === tier && preset.ratio === ratio);
|
||||
if (!selected || !Number.isInteger(value) || value <= 0) {
|
||||
setErrors((current) => ({ ...current, [tier]: "请输入正整数像素" }));
|
||||
return;
|
||||
}
|
||||
const width = field === "width" ? value : selected.width;
|
||||
const height = field === "height" ? value : selected.height;
|
||||
try {
|
||||
validateImageSize(width, height);
|
||||
const next = presets.map((preset) => preset === selected ? { ...preset, width, height, size: `${width}x${height}` } : preset);
|
||||
onChange(imageSizeConfigWithPresets(profile, next));
|
||||
setErrors((current) => ({ ...current, [tier]: "" }));
|
||||
} catch (reason) {
|
||||
setErrors((current) => ({ ...current, [tier]: reason instanceof Error ? reason.message : "像素尺寸无效" }));
|
||||
}
|
||||
};
|
||||
const addRatio = (tier: ImageResolutionTier) => {
|
||||
const ratios = (drafts[tier] || "")
|
||||
.split(/[,,\n]/)
|
||||
@@ -68,24 +88,38 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
|
||||
const selected = items.find((item) => item.ratio === ratio);
|
||||
const dimensions = selected || imagePresetForRatio(tier, ratio);
|
||||
return (
|
||||
<button
|
||||
key={ratio}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={Boolean(selected)}
|
||||
aria-label={`${tier.toUpperCase()} ${ratio}`}
|
||||
className="image-size-preset-option"
|
||||
title={`${ratio} · ${dimensions.width} × ${dimensions.height} px${selected ? " · 点击移除" : " · 点击添加"}`}
|
||||
onClick={() => update(tier, selected ? items.filter((item) => item.ratio !== ratio).map((item) => item.ratio) : [...items.map((item) => item.ratio), ratio])}
|
||||
>
|
||||
<span className="image-size-preset-option-label">
|
||||
<span>{ratio}</span>
|
||||
{selected ? <Check size={12} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
|
||||
</span>
|
||||
<small>
|
||||
{dimensions.width} × {dimensions.height}
|
||||
</small>
|
||||
</button>
|
||||
<div key={ratio} className="image-size-preset-option-wrap">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
aria-pressed={Boolean(selected)}
|
||||
aria-label={`${tier.toUpperCase()} ${ratio}`}
|
||||
className="image-size-preset-option"
|
||||
title={`${ratio} · ${dimensions.width} × ${dimensions.height} px${selected ? " · 点击移除" : " · 点击添加"}`}
|
||||
onClick={() => update(tier, selected ? items.filter((item) => item.ratio !== ratio).map((item) => item.ratio) : [...items.map((item) => item.ratio), ratio])}
|
||||
>
|
||||
<span className="image-size-preset-option-label">
|
||||
<span>{ratio}</span>
|
||||
{selected ? <Check size={12} aria-hidden="true" /> : <Plus size={12} aria-hidden="true" />}
|
||||
</span>
|
||||
<small>
|
||||
{dimensions.width} × {dimensions.height}
|
||||
</small>
|
||||
</button>
|
||||
{selected ? (
|
||||
<div className="image-size-preset-dimensions" aria-label={`${tier.toUpperCase()} ${ratio} 精确像素`}>
|
||||
<label>
|
||||
<span>宽</span>
|
||||
<InputNumber min={1} precision={0} value={selected.width} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 宽度像素`} onChange={(value) => editDimensions(tier, ratio, "width", value)} />
|
||||
</label>
|
||||
<span aria-hidden="true">×</span>
|
||||
<label>
|
||||
<span>高</span>
|
||||
<InputNumber min={1} precision={0} value={selected.height} disabled={disabled} aria-label={`${tier.toUpperCase()} ${ratio} 高度像素`} onChange={(value) => editDimensions(tier, ratio, "height", value)} />
|
||||
</label>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState, type CSSP
|
||||
import { Check, ChevronDown, Coins } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { formatPriceRange, modelQuoteDescription, modelQuoteRequest, normalizeTierResolution, priceTierSummaryLabel, priceTiersForCurrentSelection } from "@/lib/model-pricing";
|
||||
@@ -485,7 +486,8 @@ function tierSpecificationLabel(tier: NonNullable<NonNullable<AiConfig["channels
|
||||
const operation = selector.operation && selector.operation !== "*" ? operationLabels[selector.operation] || selector.operation : "";
|
||||
const details = [
|
||||
operation,
|
||||
selector.quality && selector.quality !== "*" ? selector.quality.toUpperCase() : "",
|
||||
selector.quality && selector.quality !== "*" ? imageQualityLabel(selector.quality) : "",
|
||||
selector.resolution && selector.resolution !== "*" ? imageQualityLabel(selector.resolution) : "",
|
||||
selector.size && selector.size !== "*" ? selector.size : "",
|
||||
tier.resolution !== "*" ? tierResolutionLabel(tier.resolution) : "",
|
||||
tier.videoSeconds ? tierDurationLabel(tier.videoSeconds) : "",
|
||||
|
||||
38
web/src/lib/image-pricing.ts
Normal file
38
web/src/lib/image-pricing.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { buildImageResolutionOptions } from "./image-resolution-tiers";
|
||||
import { isImageResolutionTier } from "./image-quality";
|
||||
import type { ImageCapabilityConfig } from "./model-capabilities";
|
||||
|
||||
/** 旧图片价格档把 1K/2K/4K 存在 quality;只在读取价格条件时转换。 */
|
||||
export function normalizeImagePriceSelector(input: Record<string, string>) {
|
||||
const selector = { ...input };
|
||||
for (const key of ["quality", "resolution", "size"] as const) {
|
||||
if (selector[key]) selector[key] = selector[key].trim().toLowerCase();
|
||||
}
|
||||
if (isImageResolutionTier(selector.quality) && (!selector.resolution || selector.resolution === "*")) {
|
||||
selector.resolution = selector.quality;
|
||||
delete selector.quality;
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
|
||||
export function imagePriceSelector(quality: string, size: string, profile?: ImageCapabilityConfig): Record<string, string> {
|
||||
quality = quality.trim().toLowerCase();
|
||||
size = size.trim().toLowerCase();
|
||||
const selector: Record<string, string> = {};
|
||||
if (quality && quality !== "auto" && quality !== "any") selector.quality = quality;
|
||||
if (size && size !== "auto" && size !== "any") selector.size = size;
|
||||
const pixel = buildImageResolutionOptions([size])[0];
|
||||
if (pixel) {
|
||||
selector.resolution = profile?.size.presets?.find((preset) => preset.size.toLowerCase() === size)?.tier || pixel.tier;
|
||||
} else if (isImageResolutionTier(quality)) {
|
||||
selector.resolution = quality;
|
||||
} else if (profile?.size.parameter === "aspect_ratio") {
|
||||
const tier = ({ low: "1k", medium: "2k", high: "4k" } as Record<string, string>)[quality];
|
||||
if (tier && profile.quality.supported) selector.resolution = tier;
|
||||
else if (!profile.quality.supported) {
|
||||
const tiers = new Set(profile.size.presets?.filter((preset) => preset.ratio === size).map((preset) => preset.tier));
|
||||
if (tiers.size === 1) selector.resolution = [...tiers][0];
|
||||
}
|
||||
}
|
||||
return selector;
|
||||
}
|
||||
45
web/src/lib/image-quality.ts
Normal file
45
web/src/lib/image-quality.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
const IMAGE_QUALITY_LABELS: Record<string, string> = {
|
||||
auto: "自动",
|
||||
low: "低",
|
||||
medium: "中",
|
||||
high: "高",
|
||||
xhigh: "超高",
|
||||
max: "最高",
|
||||
"1k": "1K",
|
||||
"2k": "2K",
|
||||
"4k": "4K",
|
||||
};
|
||||
|
||||
export function imageQualityLabel(value: string) {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
return IMAGE_QUALITY_LABELS[normalized] || value;
|
||||
}
|
||||
|
||||
export function imageQualityDescription(value: string) {
|
||||
switch (String(value || "").trim().toLowerCase()) {
|
||||
case "auto":
|
||||
return "由模型决定";
|
||||
case "low":
|
||||
return "更快生成";
|
||||
case "medium":
|
||||
return "均衡模式";
|
||||
case "high":
|
||||
return "优先速度";
|
||||
case "xhigh":
|
||||
return "优先质量";
|
||||
case "max":
|
||||
return "优先细节";
|
||||
case "1k":
|
||||
return "标准清晰度";
|
||||
case "2k":
|
||||
return "更高清晰度";
|
||||
case "4k":
|
||||
return "最高清晰度";
|
||||
default:
|
||||
return "模型支持的质量/分辨率";
|
||||
}
|
||||
}
|
||||
|
||||
export function isImageResolutionTier(value: string) {
|
||||
return ["1k", "2k", "4k"].includes(String(value || "").trim().toLowerCase());
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { modelRequestOptions, resolveVideoOperation, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { videoResolutionComparisonKey } from "@/lib/video-generation-options";
|
||||
import { buildImageResolutionOptions, imageResolutionOption } from "@/lib/image-resolution-tiers";
|
||||
import { imagePriceSelector, normalizeImagePriceSelector } from "@/lib/image-pricing";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import type { LogicalModelQuote, ModelQuoteRequest, ModelRequestIntent } from "@/services/api/logical-models";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
@@ -141,14 +142,7 @@ function priceSelectorForRequest(capability: ModelCapability | undefined, config
|
||||
const options = { ...modelRequestOptions(config, "image"), ...requirements?.options, ...(requirements?.imageSize ? { size: requirements.imageSize } : {}) };
|
||||
const imageQuality = String(options.quality ?? "").trim().toLowerCase();
|
||||
const imageSize = String(options.size ?? "").trim();
|
||||
if ((imageQuality === "" || imageQuality === "auto" || imageQuality === "any") && imageSize) {
|
||||
const resolution = imageResolutionOption(buildImageResolutionOptions([imageSize]), imageSize)?.tier;
|
||||
if (resolution) requested.quality = resolution;
|
||||
}
|
||||
for (const key of ["quality", "size"] as const) {
|
||||
const value = String(options[key] ?? "").trim().toLowerCase();
|
||||
if (value && value !== "auto" && value !== "any" && !requested[key]) requested[key] = value;
|
||||
}
|
||||
Object.assign(requested, imagePriceSelector(imageQuality, imageSize, modelCapabilityConfigFor(config, config.model || config.imageModel)?.image));
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
@@ -159,7 +153,7 @@ function imagePriceOperation(requirements?: ModelRequirements) {
|
||||
}
|
||||
|
||||
function priceSelectorForTier(tier: ModelPriceTier) {
|
||||
const selector = { ...(tier.selector || {}) };
|
||||
const selector = normalizeImagePriceSelector({ ...(tier.selector || {}) });
|
||||
if (!Object.keys(selector).length) {
|
||||
const resolution = normalizeTierResolution(tier.resolution);
|
||||
if (resolution !== "*") selector.vquality = resolution;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
import { AdminStatusBadge } from "./admin-ui";
|
||||
import { formatModelMargin, formatModelPrice, modelPriceFields } from "./channel-model-pricing";
|
||||
@@ -47,7 +48,8 @@ export function specificationLabel(tier: ChannelModelPriceTier) {
|
||||
return (
|
||||
[
|
||||
operation ? operations[operation] || operation : "",
|
||||
specific(selector.quality).toUpperCase(),
|
||||
imageQualityLabel(specific(selector.quality)),
|
||||
imageQualityLabel(specific(selector.resolution)),
|
||||
specific(selector.size),
|
||||
resolution.toUpperCase(),
|
||||
duration ? `${duration} 秒` : "",
|
||||
|
||||
@@ -113,7 +113,7 @@ export function validateChannelModelPrices(values: Pick<ChannelModelFormValues,
|
||||
if (tier.matchMode === "advanced") {
|
||||
if (tier.operation && tier.operation !== "*" && !operations[capability]?.includes(tier.operation)) fail("生成方式与模型能力不匹配");
|
||||
const specific = (value: string | undefined) => Boolean(value && value !== "*");
|
||||
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.size))) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
|
||||
if (!(specific(tier.operation) || (capability === "image" && (specific(tier.quality) || specific(tier.resolution) || specific(tier.size))) || (capability === "video" && specific(tier.resolution)))) fail("规格价格至少需要一个匹配条件;统一价格请选择默认价格");
|
||||
}
|
||||
const prices = tier.billingMode !== "token" ? [tier.unitPrice] : capability === "video" ? [tier.outputTokenPrice] : [tier.inputTokenPrice, tier.outputTokenPrice, tier.cachedTokenPrice];
|
||||
if (prices.some((price) => typeof price !== "number" || !Number.isFinite(price) || price < 0 || price > 1_000_000)) fail("积分价格必须是 0 到 1000000 之间的有效数值");
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import type { ModelCapabilityChoice as EditableCapability } from "@/components/model-protocol-picker";
|
||||
import type { ChannelModelFormValues as FormValues } from "./channel-model-editor-form";
|
||||
import { normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
|
||||
import { imagePriceQualityOptions, normalizeUpstreamModelKey } from "./channel-model-price-tier-form";
|
||||
import { CreditCostFields } from "./credit-cost-fields";
|
||||
import { Select } from "@/components/ui/base/select";
|
||||
|
||||
@@ -57,7 +57,7 @@ export function PriceTierFields({
|
||||
<span className="admin-price-tier-card-index">{String(ordinal).padStart(2, "0")}</span>
|
||||
<div className="admin-price-tier-card-heading-copy">
|
||||
<div className="admin-price-tier-card-title">{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}</div>
|
||||
<div className="admin-price-tier-card-summary">{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、质量或尺寸匹配"}</div>
|
||||
<div className="admin-price-tier-card-summary">{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、画质、分辨率或尺寸匹配"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-price-tier-card-actions">
|
||||
@@ -116,16 +116,19 @@ export function PriceTierFields({
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="质量/分辨率" rules={[{ required: true, message: "请选择质量或分辨率" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "任意质量", value: "*" },
|
||||
{ label: "1K", value: "1k" },
|
||||
{ label: "2K", value: "2k" },
|
||||
{ label: "4K", value: "4k" },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<>
|
||||
<Form.Item className="mb-0" name={[index, "quality"]} label="画质" rules={[{ required: true, message: "请选择画质" }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "任意画质", value: "*" },
|
||||
...imagePriceQualityOptions(),
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item className="mb-0" name={[index, "resolution"]} label="分辨率">
|
||||
<Select options={[{ label: "任意分辨率", value: "*" }, { label: "1K", value: "1k" }, { label: "2K", value: "2k" }, { label: "4K", value: "4k" }]} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{isImage ? (
|
||||
<Form.Item className="mb-0" name={[index, "size"]} label="画幅/尺寸">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import type { ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "@/services/api/wallet";
|
||||
|
||||
@@ -62,13 +63,13 @@ export function defaultPriceTier(matchMode: PriceTierMatchMode = "default"): Pri
|
||||
|
||||
export function priceTierToForm(tier: ChannelModelPriceTier): PriceTierFormValues {
|
||||
const selector = tier.selector || {};
|
||||
const hasSpecificMatch = [selector.operation, selector.quality, selector.size].some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*");
|
||||
const hasSpecificMatch = [selector.operation, selector.quality, selector.resolution, selector.size].some((value) => value && value !== "*") || (tier.resolution && tier.resolution !== "*");
|
||||
return {
|
||||
matchMode: hasSpecificMatch ? "advanced" : "default",
|
||||
operation: selector.operation || "*",
|
||||
quality: selector.quality || "*",
|
||||
size: selector.size || "*",
|
||||
resolution: tier.resolution || "*",
|
||||
resolution: selector.resolution || tier.resolution || "*",
|
||||
videoSeconds: tier.videoSeconds || 0,
|
||||
videoGenerateAudio: selector.videoGenerateAudio || "*",
|
||||
imageCount: Number(selector.imageCount || 0),
|
||||
@@ -112,6 +113,7 @@ export function skuSelectorFromForm(capability: ModelCapabilityChoice, tier: Pri
|
||||
}
|
||||
if (capability === "image") {
|
||||
if (tier.quality && tier.quality !== "*") selector.quality = tier.quality;
|
||||
if (tier.resolution && tier.resolution !== "*") selector.resolution = tier.resolution;
|
||||
if (tier.size && tier.size !== "*") selector.size = tier.size;
|
||||
}
|
||||
return selector;
|
||||
@@ -121,6 +123,10 @@ export function priceTierResolutionFromForm(capability: ModelCapabilityChoice, t
|
||||
return capability === "video" && tier.matchMode === "advanced" ? tier.resolution || "*" : "*";
|
||||
}
|
||||
|
||||
export function imagePriceQualityOptions() {
|
||||
return ["low", "medium", "high", "xhigh", "max", "auto", "1k", "2k", "4k"].map((value) => ({ label: imageQualityLabel(value), value }));
|
||||
}
|
||||
|
||||
export function priceTierVideoSecondsFromForm(capability: ModelCapabilityChoice, tier: PriceTierFormValues) {
|
||||
void capability;
|
||||
void tier;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/s
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers";
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "inspirationSourcesVisible" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers";
|
||||
type FeatureRow = {
|
||||
key: FeatureKey;
|
||||
title: string;
|
||||
@@ -17,7 +17,7 @@ type FeatureRow = {
|
||||
dependsOn?: FeatureKey;
|
||||
};
|
||||
|
||||
const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"];
|
||||
const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "inspirationSourcesVisible", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"];
|
||||
|
||||
const workspaceFeatureRows: FeatureRow[] = [
|
||||
{
|
||||
@@ -44,6 +44,12 @@ const workspaceFeatureRows: FeatureRow[] = [
|
||||
description: "允许用户配置并使用自己的模型渠道。",
|
||||
icon: <RadioTower className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
{
|
||||
key: "inspirationSourcesVisible",
|
||||
title: "素材来源署名",
|
||||
description: "显示创作首页的素材来源说明;关闭后隐藏该面板。",
|
||||
icon: <Sparkles className="size-4" aria-hidden="true" />,
|
||||
},
|
||||
];
|
||||
|
||||
const pluginFeatureRows: FeatureRow[] = [
|
||||
@@ -245,7 +251,7 @@ export default function FeatureAvailabilityPanel() {
|
||||
title="1. 用户工作台入口"
|
||||
description="先决定普通用户能进入哪些核心工作区"
|
||||
icon={<MonitorCog className="size-4" aria-hidden="true" />}
|
||||
status={<AdminStatusBadge label={`${enabledWorkspaceFeatures}/4 开放`} tone={enabledWorkspaceFeatures === 4 ? "success" : "neutral"} />}
|
||||
status={<AdminStatusBadge label={`${enabledWorkspaceFeatures}/5 开放`} tone={enabledWorkspaceFeatures === 5 ? "success" : "neutral"} />}
|
||||
>
|
||||
{workspaceFeatureRows.map((row) => (
|
||||
<FeatureSettingRow key={row.key} row={row} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
|
||||
@@ -351,6 +357,7 @@ function toEditablePayload(features: FeatureAvailability) {
|
||||
taskCenterEnabled: features.taskCenterEnabled,
|
||||
creditsEnabled: features.creditsEnabled,
|
||||
customChannelsEnabled: features.customChannelsEnabled,
|
||||
inspirationSourcesVisible: features.inspirationSourcesVisible,
|
||||
frontendModelsEnabled: features.frontendModelsEnabled,
|
||||
pluginCenterEnabled: features.pluginCenterEnabled,
|
||||
systemPluginsVisibleToUsers: features.systemPluginsVisibleToUsers,
|
||||
@@ -373,6 +380,7 @@ function parseFeatureAvailability(value: unknown): FeatureAvailability {
|
||||
taskCenterEnabled: record.taskCenterEnabled as boolean,
|
||||
creditsEnabled: record.creditsEnabled as boolean,
|
||||
customChannelsEnabled: record.customChannelsEnabled as boolean,
|
||||
inspirationSourcesVisible: record.inspirationSourcesVisible as boolean,
|
||||
frontendModelsEnabled: record.frontendModelsEnabled as boolean,
|
||||
pluginCenterEnabled: record.pluginCenterEnabled as boolean,
|
||||
systemPluginsVisibleToUsers: record.systemPluginsVisibleToUsers as boolean,
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
gap: 8px;
|
||||
padding: 0 17px 15px;
|
||||
}
|
||||
.canvas-sync-panel__conflict-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.canvas-sync-panel__actions .ant-btn { border-radius: 9px; }
|
||||
.canvas-sync-panel__secondary-actions {
|
||||
display: flex;
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { App, Button, Popover } from "antd";
|
||||
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw } from "lucide-react";
|
||||
import { CloudCheck, CloudOff, FileClock, LoaderCircle, RefreshCw, UploadCloud } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { flushCanvasStorePersistence } from "@/stores/canvas/use-canvas-store";
|
||||
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
|
||||
import { retryRemoteUserDataSync } from "@/services/user-data-sync";
|
||||
import { overwriteRemoteCanvasProject, retryRemoteUserDataSync } from "@/services/user-data-sync";
|
||||
import "./canvas-sync-status.css";
|
||||
|
||||
export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: { projectId: string; onLoadLatest: () => Promise<void>; onOpenVersions?: () => void }) {
|
||||
const { message, modal } = App.useApp();
|
||||
const { message } = App.useApp();
|
||||
const progress = useSyncProgressStore((state) => state.syncingProjects[projectId]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
@@ -78,26 +78,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
|
||||
}
|
||||
};
|
||||
|
||||
const confirmLoadLatest = () => {
|
||||
modal.confirm({
|
||||
title: "加载云端最新版本?",
|
||||
icon: <CloudOff className="canvas-sync-modal-icon" aria-hidden="true" />,
|
||||
content: (
|
||||
<div className="canvas-sync-confirm-copy">
|
||||
<p>这不是合并操作:当前编辑器内容会替换为云端版本。</p>
|
||||
<p>系统会先把本地修改保存为草稿,之后可从“版本记录”查看,不会直接丢失。</p>
|
||||
</div>
|
||||
),
|
||||
okText: "保留草稿并加载",
|
||||
cancelText: "继续编辑",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () =>
|
||||
run(async () => {
|
||||
await onLoadLatest();
|
||||
setStatusOpen(false);
|
||||
message.success("云端版本已加载,本地内容已保留为草稿");
|
||||
}),
|
||||
});
|
||||
const loadLatest = () => {
|
||||
void run(async () => {
|
||||
await onLoadLatest();
|
||||
setStatusOpen(false);
|
||||
message.success("云端版本已加载,本地内容已保留为草稿");
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
const overwriteCloud = () => {
|
||||
void run(async () => {
|
||||
await overwriteRemoteCanvasProject(projectId);
|
||||
setStatusOpen(false);
|
||||
message.success("本地版本已覆盖云端");
|
||||
}).catch(() => undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -140,8 +134,8 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
|
||||
</div>
|
||||
) : conflict ? (
|
||||
<div className="canvas-sync-panel__callout">
|
||||
<strong>先保护本地,再决定版本</strong>
|
||||
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "加载云端版前会先保留本地草稿。"} 这是替换,不是合并。</span>
|
||||
<strong>请选择保留哪一版</strong>
|
||||
<span>{progress?.draftCount ? `已保留 ${progress.draftCount} 份本地草稿。` : "本地内容仍在编辑器中。"} 加载云端版会替换当前编辑内容;用本地版覆盖会替换云端内容。</span>
|
||||
</div>
|
||||
) : cloudError ? (
|
||||
<div className="canvas-sync-panel__callout">
|
||||
@@ -184,9 +178,20 @@ export function CanvasSyncStatus({ projectId, onLoadLatest, onOpenVersions }: {
|
||||
立即重试本地保存
|
||||
</Button>
|
||||
) : null}
|
||||
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={confirmLoadLatest}>
|
||||
加载云端版
|
||||
</Button>
|
||||
{conflict ? (
|
||||
<div className="canvas-sync-panel__conflict-actions">
|
||||
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
|
||||
加载云端版
|
||||
</Button>
|
||||
<Button type="primary" block icon={<UploadCloud className="size-3.5" />} danger disabled={busy || saving || localPending || localError || Boolean(progress?.draftError)} onClick={overwriteCloud}>
|
||||
用本地版覆盖云端
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button block icon={<CloudOff className="size-3.5" />} disabled={busy || saving || localPending || localError} onClick={loadLatest}>
|
||||
加载云端版
|
||||
</Button>
|
||||
)}
|
||||
<div className="canvas-sync-panel__secondary-actions">
|
||||
{onOpenVersions ? (
|
||||
<Button
|
||||
|
||||
@@ -136,6 +136,7 @@ export function useCanvasGenerationExecutor({
|
||||
|
||||
const sourceTextContent = sourceNode?.type === CanvasNodeType.Text ? sourceNode.metadata?.content?.trim() || "" : "";
|
||||
const editingTextNode = mode === "text" && Boolean(sourceTextContent);
|
||||
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
|
||||
let generationPrompt = mode === "image" && sourceNode?.metadata?.portraitTexture ? buildPortraitTexturePrompt(prompt, sourceNode.metadata.portraitTexture) : prompt;
|
||||
if (mode === "image" && sourceNode?.metadata?.cameraControl?.enabled) {
|
||||
const cameraControl = sourceNode.metadata.cameraControl;
|
||||
@@ -143,6 +144,30 @@ export function useCanvasGenerationExecutor({
|
||||
generationPrompt = `${generationPrompt}\n${cameraPrompt}`;
|
||||
}
|
||||
const isPreparingEmptyImage = mode === "image" && sourceNode?.type === CanvasNodeType.Image && !sourceNode.metadata?.content;
|
||||
const preparationController = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
|
||||
setRunningNodeId(nodeId);
|
||||
if (markSourceStatus)
|
||||
setNodes((current) =>
|
||||
current.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
metadata: {
|
||||
...node.metadata,
|
||||
...canvasGenerationPromptMetadata(prompt, prompt),
|
||||
status: NODE_STATUS_LOADING,
|
||||
taskStage: "正在准备参考素材",
|
||||
taskProgress: 0,
|
||||
taskCreatedAt: new Date().toISOString(),
|
||||
errorDetails: undefined,
|
||||
generationErrorCode: undefined,
|
||||
resourceReloadAvailable: undefined,
|
||||
failedPromptFingerprint: undefined,
|
||||
},
|
||||
}
|
||||
: node,
|
||||
),
|
||||
);
|
||||
|
||||
let rawGenerationContext: Awaited<ReturnType<typeof hydrateNodeGenerationContext>>;
|
||||
// AutoDL/其他声明式视频协议需要结构化参考素材;只有普通
|
||||
@@ -164,7 +189,7 @@ export function useCanvasGenerationExecutor({
|
||||
const compatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, requirements);
|
||||
if (compatibilityError) throw new Error(`当前模型无法支持这组输入和参数:${compatibilityError}`);
|
||||
const referenceLimits = usesWorkflowProvider ? undefined : modelGroupReferenceLimits(effectiveConfig, generationConfig.model, mode, requirements);
|
||||
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits);
|
||||
rawGenerationContext = await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, mode, mode === "video" && Boolean(referenceLimits?.maxAudios), !promptOnly, referenceLimits, false);
|
||||
const hydratedRequirements = generationModelRequirements(mode, rawGenerationContext, sourceNode, generationConfig);
|
||||
generationConfig = buildGenerationConfig(effectiveConfig, sourceNode, mode, hydratedRequirements);
|
||||
const hydratedCompatibilityError = usesWorkflowProvider ? "" : modelCompatibilityError(generationConfig, generationConfig.model, hydratedRequirements);
|
||||
@@ -172,6 +197,8 @@ export function useCanvasGenerationExecutor({
|
||||
} catch (error) {
|
||||
const errorDetails = generationErrorMessage(error);
|
||||
message.error(errorDetails);
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -180,6 +207,8 @@ export function useCanvasGenerationExecutor({
|
||||
skillExecution = await skillRuntime.prepare({ profile: "canvas", prompt: rawGenerationContext.prompt, skills: addedSkills });
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "技能上下文加载失败");
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
let effectivePrompt = skillExecution.prompt.trim();
|
||||
@@ -194,28 +223,38 @@ export function useCanvasGenerationExecutor({
|
||||
} catch (error) {
|
||||
const errorDetails = generationErrorMessage(error);
|
||||
message.error(errorDetails);
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const promptLengthError = mode === "video" ? modelPromptLengthError(generationConfig, generationConfig.model, mode, effectivePrompt) : "";
|
||||
if (promptLengthError) {
|
||||
message.error(promptLengthError);
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
const generationContext = { ...rawGenerationContext, prompt: effectivePrompt };
|
||||
if (mode === "audio" && generationContext.characterReferences.length) {
|
||||
if (generationContext.characterReferences.length !== 1) {
|
||||
message.error("角色配音一次只能引用一个角色卡");
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
const voice = generationContext.resolvedCharacterVoices[0];
|
||||
if (!voice) {
|
||||
message.error("角色尚未绑定可用声音,无法创建角色配音任务");
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
generationConfig = { ...generationConfig, audioVoice: voice.voiceKey, audioInstructions: [voice.instructions, generationConfig.audioInstructions].filter(Boolean).join(";") };
|
||||
}
|
||||
if (!effectivePrompt && (mode === "text" || mode === "audio")) {
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -251,10 +290,13 @@ export function useCanvasGenerationExecutor({
|
||||
context: generationContext,
|
||||
});
|
||||
const duplicateConfirmationRequired = !options?.skipDuplicateConfirmation && !options?.retryContext && sourceNode?.metadata?.lastGenerationRequestFingerprint === requestFingerprint;
|
||||
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) return;
|
||||
if (duplicateConfirmationRequired && !(await confirmDuplicateSubmission())) {
|
||||
finishGenerationRequest(nodeId, preparationController);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setRunningNodeId(nodeId);
|
||||
const controller = startGenerationRequest(nodeId, nodeId, nodeId, options?.controller);
|
||||
const controller = preparationController;
|
||||
if (controller.signal.aborted) {
|
||||
finishGenerationRequest(nodeId, controller);
|
||||
setRunningNodeId(null);
|
||||
@@ -286,7 +328,6 @@ export function useCanvasGenerationExecutor({
|
||||
}
|
||||
|
||||
// 已有内容节点只是本次生成的来源;任务状态归新目标所有,不能覆盖已成功结果。
|
||||
const markSourceStatus = !sourceNode?.metadata?.content && !editingTextNode;
|
||||
const statusPrompt = sourceNode?.type === CanvasNodeType.Config ? effectivePrompt : prompt;
|
||||
if (markSourceStatus)
|
||||
setNodes((current) =>
|
||||
|
||||
@@ -146,7 +146,7 @@ export function useCanvasGenerationRetry({
|
||||
rawContext =
|
||||
hasSavedImageMetadata && !baseContext.characterReferences.length
|
||||
? null
|
||||
: await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly);
|
||||
: await hydrateNodeGenerationContext(baseContext, projectId, domainProjectId, retryMode, retryMode === "video" && supportsVideoReferenceAudio(generationConfig), !promptOnly, undefined, false);
|
||||
} catch (error) {
|
||||
const failure = generationFailureMetadata(error, retryPromptSource);
|
||||
message.error(failure.errorDetails);
|
||||
|
||||
@@ -393,7 +393,7 @@ export function useCanvasProjectLifecycle({
|
||||
const forceSaveCanvasProject = useCallback(async (): Promise<boolean> => {
|
||||
try { await persistLocalEdits(); } catch { message.error("本地保存失败,请重试"); return false; }
|
||||
try {
|
||||
const result = await forceOverwriteRemoteCanvasSync();
|
||||
const result = await forceOverwriteRemoteCanvasSync(projectId);
|
||||
message.success(result.reboundNodes > 0 ? `已保存,并修复 ${result.reboundNodes} 处媒体与素材的绑定` : "素材关联已核对,画布已保存");
|
||||
} catch (error) {
|
||||
message.error(`修复并保存失败:${error instanceof Error ? error.message : "未知错误"}`);
|
||||
|
||||
35
web/src/pages/create/creation-gallery-gesture.ts
Normal file
35
web/src/pages/create/creation-gallery-gesture.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/** 每次有意滚动切换一张;惯性衰减不翻页,再次发力无需等待惯性完全停止。 */
|
||||
export function createGalleryWheelGesture() {
|
||||
let lastAt = -Infinity;
|
||||
let steppedAt = -Infinity;
|
||||
let lastDirection = 0;
|
||||
let lastMagnitude = 0;
|
||||
let peak = 0;
|
||||
let total = 0;
|
||||
let stepped = false;
|
||||
|
||||
return (delta: number, now: number): -1 | 0 | 1 => {
|
||||
const magnitude = Math.abs(delta);
|
||||
if (magnitude < 0.5) return 0;
|
||||
const direction = delta > 0 ? 1 : -1;
|
||||
const idle = now - lastAt >= 160;
|
||||
const reversed = direction !== lastDirection;
|
||||
const renewed = stepped && now - steppedAt >= 180
|
||||
&& lastMagnitude < peak * 0.6
|
||||
&& magnitude >= Math.max(8, lastMagnitude * 1.8);
|
||||
if (idle || reversed || renewed) {
|
||||
total = 0;
|
||||
peak = 0;
|
||||
stepped = false;
|
||||
}
|
||||
lastAt = now;
|
||||
lastDirection = direction;
|
||||
lastMagnitude = magnitude;
|
||||
peak = Math.max(peak, magnitude);
|
||||
total += magnitude;
|
||||
if (stepped || total < 8) return 0;
|
||||
stepped = true;
|
||||
steppedAt = now;
|
||||
return direction;
|
||||
};
|
||||
}
|
||||
@@ -8,35 +8,19 @@
|
||||
|
||||
滚轮或左右按钮切换,切换靠 transform / opacity 的 transition 平滑过渡。 */
|
||||
|
||||
/* 内容层压住画廊,但指针事件要能穿过空白处,否则整条画廊都点不到;
|
||||
真正的交互元素由下面一条规则恢复命中。 */
|
||||
/* 标题、画廊和输入区共用文档流;短窗口通过 main 滚动,不再相互覆盖。 */
|
||||
.creation-home .creation-empty-workspace {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creation-home .creation-empty-workspace > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* 画廊占中上部、输入框沉到下方:
|
||||
先把 main 的居中改掉,否则 justify-content: center 会吃掉 margin-top: auto。 */
|
||||
.creation-home .creation-empty-workspace {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.creation-home .creation-home-heading {
|
||||
margin-top: clamp(16px, 5vh, 52px);
|
||||
.creation-home .creation-empty-workspace > * {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.creation-home .creation-empty-workspace > .creation-launchpad {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
bottom: clamp(16px, 5vh, 52px);
|
||||
width: min(960px, calc(100% - 32px));
|
||||
width: min(960px, 100%);
|
||||
margin: 0;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
/* ── 让画廊从内容区背后穿过 ──
|
||||
@@ -71,35 +55,28 @@
|
||||
text-shadow: 0 1px 14px var(--creation-bg, var(--workspace-page));
|
||||
}
|
||||
|
||||
/* ── 画廊本体 ──
|
||||
上下边界同时定义在这里,两侧切换按钮靠它们算出纵向居中位置。 */
|
||||
/* 卡片尺寸取当前内容宽度和窗口高度的较小值,含悬停位移的空间一起占位。 */
|
||||
.creation-inspiration-tunnel {
|
||||
/* 上边让开标题,下边让开输入框——前卡刚好停在输入框上沿之上。 */
|
||||
--gallery-top: clamp(120px, 14vh, 184px);
|
||||
--gallery-bottom: clamp(430px, 48vh, 550px);
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
--gallery-card-width: min(440px, 64cqw, 40dvh);
|
||||
--gallery-step: clamp(64px, 16cqw, 185px);
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
container-type: inline-size;
|
||||
width: 100%;
|
||||
flex: 1 0 auto;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.creation-gallery-stage {
|
||||
position: absolute;
|
||||
top: var(--gallery-top);
|
||||
right: 0;
|
||||
bottom: var(--gallery-bottom);
|
||||
left: 0;
|
||||
/* 只有舞台能收到滚轮,避免整页滚动被劫持。 */
|
||||
pointer-events: auto;
|
||||
position: relative;
|
||||
height: calc(var(--gallery-card-width) * 9 / 16 + 48px);
|
||||
}
|
||||
|
||||
.creation-tunnel-card {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(440px, 40vw);
|
||||
width: var(--gallery-card-width);
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
@@ -128,8 +105,8 @@
|
||||
/* 只动 transform 和 opacity,两者都能交给合成层,不触发重排重绘。
|
||||
缓动先缓后快再长尾软着陆,观感是镜头平稳推拉而不是"弹"过去。 */
|
||||
transition:
|
||||
transform 660ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
opacity 520ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transform 460ms cubic-bezier(0.32, 0.72, 0, 1),
|
||||
opacity 380ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
/* 封面已就绪:放行这张卡。 */
|
||||
@@ -224,28 +201,33 @@
|
||||
/* ── 左右切换按钮 ── */
|
||||
.creation-gallery-arrow {
|
||||
position: absolute;
|
||||
/* 对齐画廊那条带子的纵向中线,而不是整屏中线——输入框在下面,整屏中线偏低。 */
|
||||
top: calc((100% - var(--gallery-top) - var(--gallery-bottom)) / 2 + var(--gallery-top));
|
||||
top: calc((var(--gallery-card-width) * 9 / 16 + 48px) / 2);
|
||||
z-index: 180;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
padding: 0;
|
||||
color: var(--user-ink, #fff);
|
||||
background: color-mix(in srgb, var(--user-surface) 62%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--user-ink) 14%, transparent);
|
||||
color: var(--creation-bg, #fff);
|
||||
background: var(--creation-text, #1d2024);
|
||||
border: 1px solid color-mix(in srgb, var(--creation-bg, #fff) 58%, var(--creation-text, #1d2024));
|
||||
border-radius: var(--r-full, 999px);
|
||||
box-shadow: 0 14px 30px -14px rgba(0, 0, 0, 0.55);
|
||||
box-shadow:
|
||||
0 0 0 4px color-mix(in srgb, var(--creation-text, #1d2024) 10%, transparent),
|
||||
0 16px 30px -14px color-mix(in srgb, #000 64%, transparent);
|
||||
-webkit-backdrop-filter: blur(18px) saturate(1.3);
|
||||
backdrop-filter: blur(18px) saturate(1.3);
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
/* 用整条 transform 表达"居中 + 悬停放大":
|
||||
touch-action: manipulation;
|
||||
opacity: 0.96;
|
||||
/* 用整条 transform 表达“居中 + 悬停放大”:
|
||||
换成 scale 独立属性会插在 transform 之前合成,按钮会跟着往下掉。 */
|
||||
transform: translateY(-50%);
|
||||
transition:
|
||||
transform var(--user-motion, 180ms) ease,
|
||||
background-color var(--user-motion, 180ms) ease,
|
||||
box-shadow var(--user-motion, 180ms) ease,
|
||||
color var(--user-motion, 180ms) ease;
|
||||
}
|
||||
|
||||
@@ -258,7 +240,15 @@
|
||||
}
|
||||
|
||||
.creation-gallery-arrow:hover {
|
||||
background: color-mix(in srgb, var(--creation-text, #1d2024) 88%, var(--creation-accent, var(--creation-text, #1d2024)));
|
||||
transform: translateY(-50%) scale(1.08);
|
||||
box-shadow:
|
||||
0 0 0 5px color-mix(in srgb, var(--creation-text, #1d2024) 14%, transparent),
|
||||
0 18px 34px -12px color-mix(in srgb, #000 70%, transparent);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow:active {
|
||||
transform: translateY(-50%) scale(0.96);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow:focus-visible {
|
||||
@@ -290,12 +280,16 @@
|
||||
|
||||
/* 素材来源署名:CC BY 4.0 要求可追溯,画廊形态下不再有灵感分区页脚。 */
|
||||
.creation-inspiration-credit {
|
||||
position: absolute;
|
||||
right: 22px;
|
||||
bottom: 14px;
|
||||
z-index: 2;
|
||||
max-width: 62ch;
|
||||
color: var(--creation-faint, var(--user-ink-soft));
|
||||
align-self: flex-end;
|
||||
margin-top: var(--space-4);
|
||||
max-width: min(62ch, 100%);
|
||||
padding: 7px 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--user-ink, #fff) 16%, transparent);
|
||||
border-radius: var(--r-md, 10px);
|
||||
background: color-mix(in srgb, var(--user-surface, #101214) 86%, transparent);
|
||||
box-shadow: 0 8px 24px color-mix(in srgb, #000 16%, transparent);
|
||||
backdrop-filter: blur(10px);
|
||||
color: var(--user-ink, #fff);
|
||||
font-size: var(--fs-caption, 12px);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -347,30 +341,13 @@
|
||||
|
||||
/* 窄屏:横向偏移收小一点,免得侧翼卡片被裁掉。 */
|
||||
@media (max-width: 1100px) {
|
||||
.creation-inspiration-tunnel {
|
||||
--gallery-top: clamp(72px, 11vh, 132px);
|
||||
--gallery-bottom: clamp(210px, 33vh, 320px);
|
||||
}
|
||||
|
||||
.creation-tunnel-card {
|
||||
width: min(400px, 48vw);
|
||||
}
|
||||
|
||||
.creation-gallery-arrow {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.creation-inspiration-credit {
|
||||
right: 14px;
|
||||
}
|
||||
|
||||
.creation-tunnel-card {
|
||||
width: 68vw;
|
||||
}
|
||||
|
||||
.creation-tunnel-card-desc {
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
@@ -20,12 +20,11 @@ import { useReducedMotion } from "motion/react";
|
||||
import { catalogIdOf, type CreationInspiration } from "@/lib/inspirations/catalog";
|
||||
import { loadGalleryPool } from "@/services/inspiration-catalog";
|
||||
import type { CreationMode } from "./creation-types";
|
||||
import { createGalleryWheelGesture } from "./creation-gallery-gesture";
|
||||
import "./creation-inspiration-tunnel.css";
|
||||
|
||||
/** 画廊里排队展示的照片数,取奇数,两侧层数才对得齐。 */
|
||||
const GALLERY_SIZE = 11;
|
||||
/** 每后退一层的横向偏移(px)。 */
|
||||
const STEP_X = 185;
|
||||
/** 每后退一层的纵深(px)。 */
|
||||
const STEP_Z = 150;
|
||||
/** 虚拟焦距,只用来算出"后退一层缩多少",不再是 CSS perspective。 */
|
||||
@@ -181,24 +180,23 @@ export function CreationInspirationTunnel({ mode, onStartPrompt }: { mode: Creat
|
||||
return () => document.removeEventListener("visibilitychange", handleVisibility);
|
||||
}, []);
|
||||
|
||||
// 滚轮切换:向上退到上一张、向下进到下一张。
|
||||
// 只在画廊区域消费滚轮,缩放手势仍由浏览器处理。
|
||||
useEffect(() => {
|
||||
const node = stageRef.current;
|
||||
if (!node) return;
|
||||
let latched = false;
|
||||
const gesture = createGalleryWheelGesture();
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey || shots.length < 2) return;
|
||||
const rawDelta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
|
||||
const delta = rawDelta * (event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? node.clientHeight : 1);
|
||||
if (Math.abs(delta) < 0.5) return;
|
||||
event.preventDefault();
|
||||
// 触控板一次滑动会连发多个事件,锁一小段时间,避免一次滑过好几张。
|
||||
if (latched) return;
|
||||
latched = true;
|
||||
window.setTimeout(() => {
|
||||
latched = false;
|
||||
}, 240);
|
||||
step(event.deltaY > 0 ? 1 : -1);
|
||||
const direction = gesture(delta, performance.now());
|
||||
if (direction) step(direction);
|
||||
};
|
||||
node.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => node.removeEventListener("wheel", handleWheel);
|
||||
}, [step]);
|
||||
}, [step, shots.length]);
|
||||
|
||||
const current = shots[active];
|
||||
|
||||
@@ -217,9 +215,9 @@ export function CreationInspirationTunnel({ mode, onStartPrompt }: { mode: Creat
|
||||
// 超出可见层数就靠透明度淡出,不切 visibility——
|
||||
// 硬切会让最远那张在过渡中途"啪"地消失。
|
||||
const style = {
|
||||
"--card-x": `${(Math.sign(relative) * STEP_X * depth).toFixed(1)}px`,
|
||||
"--card-x": `calc(${relative} * var(--gallery-step))`,
|
||||
"--card-scale": depthScale(depth).toFixed(4),
|
||||
"--card-depth-opacity": `${DEPTH_OPACITY[Math.min(depth, DEPTH_OPACITY.length - 1)]}`,
|
||||
"--card-depth-opacity": depth > MAX_DEPTH ? "0" : `${DEPTH_OPACITY[depth]}`,
|
||||
zIndex: `${100 - depth}`,
|
||||
} as CSSProperties;
|
||||
|
||||
|
||||
@@ -272,3 +272,17 @@ html:not(.dark) .app-product-overlays .creation-control-popover .ant-popover-inn
|
||||
html:not(.dark) .app-product-overlays .creation-control-popover-surface {
|
||||
box-shadow: 0 16px 40px rgba(25, 25, 30, .12) !important;
|
||||
}
|
||||
|
||||
/* 长提示词保持编辑器可控:按当前字号最多展示约 12 行,继续输入时只在编辑区内部滚动。 */
|
||||
.creation-home .creation-chat-editor textarea,
|
||||
.creation-home .creation-chat-mention-editor {
|
||||
max-height: 18.6em !important;
|
||||
overflow-x: hidden !important;
|
||||
overflow-y: auto !important;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.creation-home .creation-chat-mention-container {
|
||||
min-height: 0 !important;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { GenerationRetryContext } from "@/lib/canvas/canvas-project-generation";
|
||||
import { formatVideoResolutionLabel as videoResolutionLabel, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { imageQualityDescription, imageQualityLabel } from "@/lib/image-quality";
|
||||
import type { CreationAttachment, CreationMode } from "./creation-assets";
|
||||
import type { CreationReference } from "./creation-references";
|
||||
|
||||
@@ -49,13 +50,7 @@ export const ratioOptions = [
|
||||
{ value: "21:9", label: "宽银幕" },
|
||||
];
|
||||
export const qualityOptions = [
|
||||
{ value: "auto", label: "自动", description: "由模型决定" },
|
||||
{ value: "low", label: "低", description: "更快生成" },
|
||||
{ value: "medium", label: "中", description: "均衡模式" },
|
||||
{ value: "high", label: "高", description: "优先细节" },
|
||||
// grok2api / xAI Imagine:quality 映射 resolution
|
||||
{ value: "1k", label: "1K", description: "标准清晰度" },
|
||||
{ value: "2k", label: "2K", description: "更高清晰度" },
|
||||
...["auto", "low", "medium", "high", "xhigh", "max", "1k", "2k", "4k"].map((value) => ({ value, label: imageQualityLabel(value), description: imageQualityDescription(value) })),
|
||||
];
|
||||
export const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: videoResolutionLabel(value) }));
|
||||
export const countOptions = ["1", "2", "3", "4"];
|
||||
|
||||
@@ -22,6 +22,7 @@ import { formatShotOrdinal } from "@/lib/shot-label";
|
||||
import { buildImageResolutionOptions, formatImageResolutionSize, supportsImageResolutionPresets } from "@/lib/image-resolution-tiers";
|
||||
import { normalizeVideoValue, videoDurationOptions, type ImageCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { mergedImageCapabilityConfig, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import { quoteModel, type LogicalModelQuote } from "@/services/api/logical-models";
|
||||
import { modelOptionName, resolveModelChannel, type AiConfig } from "@/stores/use-config-store";
|
||||
@@ -471,7 +472,7 @@ function ModePicker({ mode, onModeChange }: { mode: CreationMode; onModeChange:
|
||||
|
||||
function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: value.toUpperCase(), description: "模型支持的质量/分辨率" });
|
||||
const activeQualityOptions = props.imageProfile.quality.values.map((value) => qualityOptions.find((item) => item.value === value) || { value, label: imageQualityLabel(value), description: "模型支持的质量/分辨率" });
|
||||
const qualityLabel = activeQualityOptions.find((item) => item.value === props.quality)?.label || qualityOptions.find((item) => item.value === props.quality)?.label || props.quality || "自动";
|
||||
// 尺寸/比例/分辨率选项取同显示名分组内全部模型的并集,路由模型只决定发送参数。
|
||||
const mergedProfile = mergedImageCapabilityConfig(props.config, props.model || props.config.imageModel);
|
||||
|
||||
@@ -103,6 +103,7 @@ export default function CreatePage() {
|
||||
const [hydrated, setHydrated] = useState(false);
|
||||
const userId = useUserStore((state) => state.user?.id || null);
|
||||
const userSessionHydrated = useUserStore((state) => state.hydrated);
|
||||
const inspirationSourcesVisible = useUserStore((state) => state.features.inspirationSourcesVisible);
|
||||
const [conversationScope, setConversationScope] = useState<string | null>(null);
|
||||
const [mode, setMode] = useState<CreationMode>(() => initialComposerPreferences.mode || defaultCreationMode);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
@@ -1064,15 +1065,15 @@ export default function CreatePage() {
|
||||
}}><Maximize2 /></button></Tooltip>
|
||||
</motion.div> : null}
|
||||
</AnimatePresence>
|
||||
<CreationInspirationTunnel
|
||||
mode={mode}
|
||||
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||||
/>
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-empty-workspace creation-scrollbar">
|
||||
<div className="creation-home-heading">
|
||||
<h1>和{brandName}聊聊创作想法</h1>
|
||||
<p>从一个画面、一个角色或一句话开始,继续你的创作。</p>
|
||||
</div>
|
||||
<CreationInspirationTunnel
|
||||
mode={mode}
|
||||
onStartPrompt={(nextMode, prompt) => { setAgentMode(false); selectMode(nextMode); setPrompt(prompt); window.requestAnimationFrame(() => composerFocusRef.current?.focus()); }}
|
||||
/>
|
||||
<section ref={launchpadRef} className="creation-launchpad" aria-label="开始创作">
|
||||
<div className={cn("creation-composer-stage is-home-mode", agentMode && "is-agent-mode")}>
|
||||
<CreationModeTabs mode={mode} agentActive={agentMode} onAgentSelect={() => setAgentMode(true)} onModeChange={(next) => { setAgentMode(false); selectMode(next); }} />
|
||||
@@ -1083,8 +1084,7 @@ export default function CreatePage() {
|
||||
onOpenLibrary={() => { setAgentMode(false); selectMode("image"); setLibraryOpen(true); }}
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
<div className="creation-inspiration-credit">
|
||||
{inspirationSourcesVisible ? <div className="creation-inspiration-credit">
|
||||
<details>
|
||||
<summary>素材来源</summary>
|
||||
{declaredInspirationSources().map((source) => (
|
||||
@@ -1101,7 +1101,8 @@ export default function CreatePage() {
|
||||
</p>
|
||||
))}
|
||||
</details>
|
||||
</div>
|
||||
</div> : null}
|
||||
</main>
|
||||
</> : <div className="creation-thread-workbench">
|
||||
<CreationWorkspaceToolbar onNewConversation={startNewConversation} onOpenHistory={() => setHistoryOpen(true)} shots={videoShots} onJumpToShot={jumpToShot} onContinueCanvas={() => void continueOnCanvas()} openingCanvas={openingCanvas} />
|
||||
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-thread-scroll creation-scrollbar">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useDebouncedValue } from "@/hooks/use-debounced-value";
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { imageQualityLabel } from "@/lib/image-quality";
|
||||
import { modelQuoteDescription, modelQuoteRequest } from "@/lib/model-pricing";
|
||||
import { customShotTitle, formatShotOrdinal, normalizeDefaultShotTitle } from "@/lib/shot-label";
|
||||
import { modelCompatibilityError, resolveCompatibleModel, resolveModelVideoBooleanOptions, type ModelRequirements } from "@/lib/model-selection";
|
||||
@@ -516,7 +517,7 @@ export default function WorkflowProductionWorkbench(props: Props) {
|
||||
{generationCapability === "video" ? (
|
||||
<Form.Item label="分辨率"><Select value={resolution} onChange={setResolution} options={(videoProfile?.resolutions || []).map((value) => ({ value, label: formatVideoResolutionLabel(value) }))} /></Form.Item>
|
||||
) : imageProfile?.quality.supported && !imageResolutionUsesQuality(imageProfile) ? (
|
||||
<Form.Item label="生成画质"><Select value={imageQuality} onChange={setImageQuality} options={imageProfile.quality.values.map((value) => ({ value, label: value.toUpperCase() }))} /></Form.Item>
|
||||
<Form.Item label="生成画质"><Select value={imageQuality} onChange={setImageQuality} options={imageProfile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }))} /></Form.Item>
|
||||
) : <div />}
|
||||
</div>
|
||||
{generationCapability === "image" && imageProfile ? <ImageSizePicker profile={imageProfile} size={aspectRatio} quality={imageQuality} onChange={(size, quality) => { setAspectRatio(size); if (quality) setImageQuality(quality); }} /> : null}
|
||||
|
||||
@@ -439,7 +439,7 @@ export function getAdminFeatureAvailability() {
|
||||
}
|
||||
|
||||
export function updateAdminFeatureAvailability(
|
||||
features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers">>,
|
||||
features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "inspirationSourcesVisible">>,
|
||||
) {
|
||||
return http.patch<{ features: FeatureAvailability }>("/admin/settings/features", features);
|
||||
}
|
||||
|
||||
26
web/src/services/skill-defaults.ts
Normal file
26
web/src/services/skill-defaults.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { addSkill, getSkill, listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
|
||||
/** 新画布 Agent 的单一默认技能;已有画布的会话选择不由此常量覆盖。 */
|
||||
export const DEFAULT_CANVAS_AGENT_SKILL_ID = "yingce-short-drama-workflow";
|
||||
|
||||
export type AddedSkillsResult = { skills: Skill[] };
|
||||
|
||||
/**
|
||||
* 确保默认技能既存在于用户技能库,也能作为本轮 Agent 的已选技能使用。
|
||||
* 先读已安装列表,只有首次需要时才写入用户技能状态。
|
||||
*/
|
||||
export async function ensureDefaultCanvasAgentSkill(dependencies: {
|
||||
listAdded?: typeof listAddedSkills;
|
||||
get?: typeof getSkill;
|
||||
add?: typeof addSkill;
|
||||
} = {}): Promise<AddedSkillsResult> {
|
||||
const list = dependencies.listAdded || listAddedSkills;
|
||||
const get = dependencies.get || getSkill;
|
||||
const add = dependencies.add || addSkill;
|
||||
const added = await list();
|
||||
if (added.skills.some((skill) => skill.skillId === DEFAULT_CANVAS_AGENT_SKILL_ID && skill.isAdded)) return added;
|
||||
|
||||
const detail = await get(DEFAULT_CANVAS_AGENT_SKILL_ID);
|
||||
if (!detail.skill.isAdded) await add(DEFAULT_CANVAS_AGENT_SKILL_ID);
|
||||
return list();
|
||||
}
|
||||
@@ -403,7 +403,9 @@ export function installRemoteUserDataAutoSync() {
|
||||
if (state.projects === previous.projects) return;
|
||||
const before = new Map(previous.projects.map((project) => [project.id, project]));
|
||||
const changed = state.projects.filter((project) => !sameCanvasContent(before.get(project.id), project));
|
||||
if (!changed.length && state.projects.length === previous.projects.length) return;
|
||||
// Viewport writes are local persistence only. They must never enqueue a
|
||||
// cloud sync, even while the local persistence queue is being flushed.
|
||||
if (!changed.length) return;
|
||||
if (remoteUserDataPhase === "ready") {
|
||||
for (const project of changed) {
|
||||
if (sameCanvasContent(acknowledgedProjects.get(project.id), project)) continue;
|
||||
@@ -704,13 +706,20 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { force?: boolean; repairMissingResources?: boolean }) {
|
||||
const projectId = typeof input === "string" || Array.isArray(input) ? (input as string | readonly string[]) : undefined;
|
||||
const options = input && typeof input === "object" && !Array.isArray(input) ? (input as { force?: boolean; repairMissingResources?: boolean }) : {};
|
||||
export async function saveRemoteUserDataNow(input?: string | readonly string[] | { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean }) {
|
||||
const projectId = typeof input === "string" || Array.isArray(input)
|
||||
? (input as string | readonly string[])
|
||||
: input && typeof input === "object" && !Array.isArray(input)
|
||||
? (input as { projectId?: string | readonly string[] }).projectId
|
||||
: undefined;
|
||||
const options = input && typeof input === "object" && !Array.isArray(input)
|
||||
? (input as { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean })
|
||||
: {};
|
||||
const epoch = sessionEpoch;
|
||||
if (!activeRemoteUserId) throw new Error("云端同步会话尚未建立,当前内容已保留在本地");
|
||||
requireRemoteUserDataBaseline();
|
||||
const assertNoConflict = () => {
|
||||
if (options.force) return;
|
||||
const ids = projectId === undefined ? useCanvasStore.getState().projects.map((project) => project.id) : typeof projectId === "string" ? [projectId] : projectId;
|
||||
if (ids.some((id) => useSyncProgressStore.getState().syncingProjects[id]?.phase === "conflict")) {
|
||||
throw new ApiError("云端画布已有更新,请保留本地草稿并加载最新版本", { status: 409 });
|
||||
@@ -757,7 +766,12 @@ export async function saveRemoteUserDataNow(input?: string | readonly string[] |
|
||||
* 修复媒体与素材的绑定,再按素材先于画布的顺序保存。
|
||||
* 素材修复可采用远端素材基线;画布始终携带原始 revision,不能绕过版本校验。
|
||||
*/
|
||||
export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebindResult> {
|
||||
export async function overwriteRemoteCanvasProject(projectId: string) {
|
||||
if (!projectId.trim()) throw new Error("缺少画布 ID,无法覆盖云端");
|
||||
await saveRemoteUserDataNow({ projectId, force: true });
|
||||
}
|
||||
|
||||
export async function forceOverwriteRemoteCanvasSync(projectId?: string): Promise<CanvasAssetRebindResult> {
|
||||
const epoch = sessionEpoch;
|
||||
if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试");
|
||||
requireRemoteUserDataBaseline();
|
||||
@@ -765,11 +779,11 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
|
||||
const rebind = await withRemoteUserDataSyncExclusive(async () => {
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
|
||||
requireRemoteUserDataBaseline();
|
||||
await repairMissingCanvasVideoPreviews();
|
||||
const selectedProjectIds = projectId === undefined ? undefined : new Set([projectId]);
|
||||
await repairMissingCanvasVideoPreviews(selectedProjectIds);
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
|
||||
const projects = useCanvasStore.getState().projects;
|
||||
// 服务端素材记录是 guard 实际校验的事实;本地缓存可能落后,须先取回再判定绑定一致性。
|
||||
const claimedIds = [...collectCanvasMediaAssetIds(projects)];
|
||||
const claimedIds = [...collectCanvasMediaAssetIds(selectedProjectIds ? projects.filter((project) => selectedProjectIds.has(project.id)) : projects)];
|
||||
const remoteAssets: Asset[] = [];
|
||||
for (let offset = 0; offset < claimedIds.length; offset += 100) {
|
||||
const { assets } = await getRemoteAssetsByIds(claimedIds.slice(offset, offset + 100));
|
||||
@@ -778,15 +792,15 @@ export async function forceOverwriteRemoteCanvasSync(): Promise<CanvasAssetRebin
|
||||
if (epoch !== sessionEpoch) throw new Error("账号已切换,已停止修复保存");
|
||||
const remoteById = new Map(remoteAssets.map((asset) => [asset.id, asset]));
|
||||
const merged = [...remoteAssets, ...useAssetStore.getState().assets.filter((asset) => !remoteById.has(asset.id))];
|
||||
const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged));
|
||||
const result = rebindInconsistentCanvasAssets(parseAssetRecordList(merged), selectedProjectIds);
|
||||
await Promise.all([flushCanvasStorePersistence(), flushAssetStorePersistence()]);
|
||||
return result;
|
||||
});
|
||||
await saveRemoteUserDataNow({ force: true, repairMissingResources: true });
|
||||
await saveRemoteUserDataNow({ projectId, force: true, repairMissingResources: true });
|
||||
return rebind;
|
||||
}
|
||||
|
||||
async function drainRemoteUserDataChanges(options: { force?: boolean; repairMissingResources?: boolean } = {}) {
|
||||
async function drainRemoteUserDataChanges(options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) {
|
||||
const uploaded = new Map<string, string>();
|
||||
do {
|
||||
syncQueued = false;
|
||||
@@ -794,20 +808,37 @@ async function drainRemoteUserDataChanges(options: { force?: boolean; repairMiss
|
||||
} while (syncQueued);
|
||||
}
|
||||
|
||||
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { force?: boolean; repairMissingResources?: boolean } = {}) {
|
||||
async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: { projectId?: string | readonly string[]; force?: boolean; repairMissingResources?: boolean } = {}) {
|
||||
// 中央兜底:任何调用方只要把持久媒体写进画布,提交前都会先补齐素材记录与 assetId。
|
||||
// 页面级入口仍主动入库,以便立即反馈;这里负责阻止遗漏入口形成远端幽灵资源。
|
||||
const projectsBeforeRepair = useCanvasStore.getState().projects;
|
||||
const selectedProjectIds = options.projectId === undefined
|
||||
? undefined
|
||||
: new Set(typeof options.projectId === "string" ? [options.projectId] : options.projectId);
|
||||
const syncProjectIds = selectedProjectIds === undefined
|
||||
? undefined
|
||||
: new Set([...selectedProjectIds].filter((id) => projectsBeforeRepair.some((project) => project.id === id)));
|
||||
const changedProjectIds = new Set(
|
||||
useCanvasStore
|
||||
.getState()
|
||||
.projects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project))
|
||||
projectsBeforeRepair
|
||||
.filter((project) => (syncProjectIds === undefined || syncProjectIds.has(project.id)) && !sameCanvasContent(acknowledgedProjects.get(project.id), project))
|
||||
.map((project) => project.id),
|
||||
);
|
||||
repairMissingCanvasAssets(changedProjectIds, incrementalSession);
|
||||
const currentProjects = useCanvasStore.getState().projects;
|
||||
const currentAssets = useAssetStore.getState().assets;
|
||||
const dirtyProjects = currentProjects.filter((project) => !sameCanvasContent(acknowledgedProjects.get(project.id), project) && useSyncProgressStore.getState().syncingProjects[project.id]?.phase !== "conflict");
|
||||
const dirtyAssets = currentAssets.filter((asset) => !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset));
|
||||
const dirtyProjects = currentProjects.filter((project) => {
|
||||
const dirty = !sameCanvasContent(acknowledgedProjects.get(project.id), project);
|
||||
const conflicted = useSyncProgressStore.getState().syncingProjects[project.id]?.phase === "conflict";
|
||||
const selected = selectedProjectIds === undefined || selectedProjectIds.has(project.id);
|
||||
return selected && dirty && (options.force || !conflicted);
|
||||
});
|
||||
const scopedAssetIds = options.force && syncProjectIds
|
||||
? new Set(collectCanvasMediaAssetIds(currentProjects.filter((project) => syncProjectIds.has(project.id))))
|
||||
: undefined;
|
||||
const dirtyAssets = currentAssets.filter((asset) => {
|
||||
const dirty = !sameEntitySnapshot(acknowledgedAssets.get(asset.id), asset);
|
||||
return dirty && (scopedAssetIds === undefined || scopedAssetIds.has(asset.id));
|
||||
});
|
||||
if (!dirtyProjects.length && !dirtyAssets.length) return;
|
||||
|
||||
const adoptedAssetIds = new Set<string>();
|
||||
@@ -1006,6 +1037,17 @@ async function saveRemoteUserDataBatch(uploaded: Map<string, string>, options: {
|
||||
saved = true;
|
||||
continue;
|
||||
}
|
||||
if (options.force) {
|
||||
const remoteHash = await canvasContentHash(remote);
|
||||
const forced = { ...current, revision: remote.revision, remoteContentHash: remoteHash };
|
||||
acknowledgedProjects.set(source.id, { ...remote, viewport: current.viewport, remoteContentHash: remoteHash });
|
||||
verifiedProjects.add(source.id);
|
||||
useCanvasStore.setState((state) => ({ projects: state.projects.map((project) => (project.id === source.id ? forced : project)) }));
|
||||
await flushCanvasStorePersistence();
|
||||
candidate = forced;
|
||||
useSyncProgressStore.getState().setProjectProgress(source.id, { phase: "reconciling", message: "正在用本地版本覆盖云端" });
|
||||
continue;
|
||||
}
|
||||
const baseline = acknowledgedProjects.get(source.id);
|
||||
if (!baseline) throw error;
|
||||
let rebased: CanvasProject;
|
||||
@@ -1077,7 +1119,7 @@ function sameEntitySnapshot<T>(acknowledged: T | undefined, current: T) {
|
||||
|
||||
function sanitizeCanvasProjectForRemoteSync<T>(project: T): T {
|
||||
if (!project || typeof project !== "object") return project;
|
||||
const clone = { ...(project as Record<string, unknown>) };
|
||||
const { viewport: _viewport, ...clone } = project as Record<string, unknown>;
|
||||
if (Array.isArray(clone.chatSessions)) {
|
||||
clone.chatSessions = clone.chatSessions.map((session) => {
|
||||
if (!session || typeof session !== "object") return session;
|
||||
|
||||
@@ -33,6 +33,7 @@ export type FeatureAvailability = {
|
||||
frontendModelsEnabled: boolean;
|
||||
pluginCenterEnabled: boolean;
|
||||
systemPluginsVisibleToUsers: boolean;
|
||||
inspirationSourcesVisible: boolean;
|
||||
configured?: boolean;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
@@ -47,6 +48,7 @@ export const defaultFeatureAvailability: FeatureAvailability = {
|
||||
frontendModelsEnabled: false,
|
||||
pluginCenterEnabled: true,
|
||||
systemPluginsVisibleToUsers: true,
|
||||
inspirationSourcesVisible: true,
|
||||
};
|
||||
|
||||
type UserStore = {
|
||||
|
||||
@@ -18219,7 +18219,9 @@ html:not(.dark) .creation-home .creation-user-message-copy-wrap { box-shadow: 0
|
||||
flex: 1 1 auto !important;
|
||||
}
|
||||
.app-user-workspace .creation-home > .creation-empty-workspace {
|
||||
display: block !important;
|
||||
display: flex !important;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: min(1680px, calc(100% - 72px)) !important;
|
||||
height: 100% !important;
|
||||
max-width: none !important;
|
||||
|
||||
@@ -78,6 +78,35 @@ function videoConfig() {
|
||||
}
|
||||
|
||||
describe("canvas character reference delivery", () => {
|
||||
test("saved resource references stay as storage keys without browser materialization", async () => {
|
||||
const { images } = mockResources([]);
|
||||
const context = contextFor([node("scene", CanvasNodeType.Image, { storageKey: "resource:scene" })], "@图片1");
|
||||
|
||||
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, undefined, false);
|
||||
|
||||
expect(hydrated.referenceImages).toEqual([expect.objectContaining({ storageKey: "resource:scene", dataUrl: "" })]);
|
||||
expect(images).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("character resource references also skip browser materialization", async () => {
|
||||
const { images } = mockResources([detail("a")]);
|
||||
const context = contextFor([characterNode("a")], "@角色1");
|
||||
|
||||
const hydrated = await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, undefined, false);
|
||||
|
||||
expect(hydrated.referenceImages.map((image) => image.storageKey)).toEqual(["resource:a-main"]);
|
||||
expect(images).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("local browser-only references still materialize for generation", async () => {
|
||||
const { images } = mockResources([]);
|
||||
const context = contextFor([node("scene", CanvasNodeType.Image, { storageKey: "image:local-scene" })], "@图片1");
|
||||
|
||||
await hydrateNodeGenerationContext(context, "canvas", undefined, "video", false, true, undefined, false);
|
||||
|
||||
expect(images).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("mixed references preserve primary slots through hydration and backend task preparation", async () => {
|
||||
const a = detail("a", [
|
||||
["primary", "a-main"],
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
resetRemoteUserDataSync,
|
||||
saveRemoteUserDataNow,
|
||||
syncRemoteUserData,
|
||||
overwriteRemoteCanvasProject,
|
||||
} from "../src/services/user-data-sync";
|
||||
import { createAgentCanvasSync } from "../src/services/agent-canvas-sync";
|
||||
import { flushCanvasStorePersistence, useCanvasStore, type CanvasProject } from "../src/stores/canvas/use-canvas-store";
|
||||
@@ -216,6 +217,17 @@ test("stale viewport, no-op restore and open do not submit old content", async (
|
||||
expect(requests.filter((request) => request.method === "put")).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("explicit local overwrite replaces only the selected cloud canvas after a revision conflict", async () => {
|
||||
remote.set("canvas", { ...canvas(), title: "cloud edit", revision: 2 });
|
||||
useCanvasStore.getState().renameProject("canvas", "local edit");
|
||||
|
||||
await overwriteRemoteCanvasProject("canvas");
|
||||
|
||||
expect(remote.get("canvas")).toMatchObject({ title: "local edit", revision: 3 });
|
||||
expect(remote.get("other")).toMatchObject({ title: "other", revision: 1 });
|
||||
expect(useSyncProgressStore.getState().syncingProjects.canvas).toMatchObject({ phase: "done", cloudRevision: 3, pendingChanges: 0 });
|
||||
});
|
||||
|
||||
test("load latest, save and reload stay synced when Agent history replays an unversioned delta", async () => {
|
||||
remote.set("canvas", { ...canvas(), revision: 9 });
|
||||
await loadCanvasProjectForEditing("canvas", { latest: true });
|
||||
|
||||
39
web/test/creation-gallery-gesture.test.ts
Normal file
39
web/test/creation-gallery-gesture.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { createGalleryWheelGesture } from "../src/pages/create/creation-gallery-gesture";
|
||||
|
||||
describe("创作轮播连续滚动", () => {
|
||||
test("一次触摸板滑动的加速及惯性尾巴只切换一张", () => {
|
||||
const gesture = createGalleryWheelGesture();
|
||||
const deltas = [2, 6, 18, 42, 70, 55, 32, 18, 8, 4, 2, 1];
|
||||
expect(deltas.map((delta, index) => gesture(delta, index * 30)).filter(Boolean)).toEqual([1]);
|
||||
});
|
||||
|
||||
test("惯性尚未结束时再次同方向发力仍能翻页", () => {
|
||||
const gesture = createGalleryWheelGesture();
|
||||
const deltas = [20, 65, 50, 30, 15, 7, 3, 1, 4, 18, 45, 30, 15, 5];
|
||||
expect(deltas.map((delta, index) => gesture(delta, index * 30)).filter(Boolean)).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
test("短暂停顿后的轻滑不受上一次大力度峰值限制", () => {
|
||||
const gesture = createGalleryWheelGesture();
|
||||
expect(gesture(160, 0)).toBe(1);
|
||||
expect(gesture(20, 40)).toBe(0);
|
||||
expect(gesture(4, 220)).toBe(0);
|
||||
expect(gesture(5, 240)).toBe(1);
|
||||
expect(gesture(8, 440)).toBe(1);
|
||||
});
|
||||
|
||||
test("反向手势立即生效,微小抖动不触发", () => {
|
||||
const gesture = createGalleryWheelGesture();
|
||||
expect(gesture(20, 0)).toBe(1);
|
||||
expect(gesture(-0.2, 20)).toBe(0);
|
||||
expect(gesture(-12, 40)).toBe(-1);
|
||||
expect(gesture(-6, 60)).toBe(0);
|
||||
expect(gesture(12, 80)).toBe(1);
|
||||
});
|
||||
|
||||
test("离散滚轮每次滚动均可连续切换", () => {
|
||||
const gesture = createGalleryWheelGesture();
|
||||
expect([0, 200, 400, 600].map((now) => gesture(48, now))).toEqual([1, 1, 1, 1]);
|
||||
});
|
||||
});
|
||||
@@ -6,11 +6,17 @@ import { ImageSizePresetsEditor } from "../src/components/image-size-presets-edi
|
||||
import { ModelCapabilityEditor } from "../src/components/model-capability-editor";
|
||||
import { defaultImageCapabilityConfig, normalizeImageValue, normalizeModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "../src/lib/image-size-presets";
|
||||
import { imageQualityLabel } from "../src/lib/image-quality";
|
||||
import { buildImageResolutionOptions } from "../src/lib/image-resolution-tiers";
|
||||
import { resolveImageRequestSize, validateImageSize } from "../src/services/api/image-validation";
|
||||
import { buildGeminiImageGenerationConfig } from "../src/lib/gemini-image";
|
||||
|
||||
describe("统一图片分辨率与宽高比", () => {
|
||||
test("画质公共映射覆盖创作与画布使用的 xhigh、max", () => {
|
||||
expect(imageQualityLabel("xhigh")).toBe("超高");
|
||||
expect(imageQualityLabel("MAX")).toBe("最高");
|
||||
});
|
||||
|
||||
test("管理员档位开关按已有比例启用,部分比例不显示为关闭", () => {
|
||||
for (const model of ["midjourney-1k", "midjourney-2k"]) {
|
||||
const profile = defaultImageCapabilityConfig("cangyuan-midjourney-v82", model);
|
||||
|
||||
@@ -140,6 +140,18 @@ describe("model request pricing", () => {
|
||||
expect(priceTiersForCurrentSelection(tiers, "image", { ...config, quality: "4k" })[0]?.unitPriceMicrocredits).toBe(1_000_000);
|
||||
});
|
||||
|
||||
test("匹配独立图片画质与像素分辨率,并兼容存量 2K 价格档", () => {
|
||||
const config = systemConfig({
|
||||
capability: "image",
|
||||
tiers: [
|
||||
{ selector: { quality: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 2_000_000 },
|
||||
{ selector: { quality: "medium", resolution: "2k" }, billingMode: "fixed_request", unitPriceMicrocredits: 3_000_000 },
|
||||
],
|
||||
});
|
||||
const requirements: ModelRequirements = { capability: "image", options: { quality: "medium", size: "2048x2048" } };
|
||||
expect(requestCreditCost({ channelMode: "remote", modelCosts: resolveModelChannel(config, config.model).modelCosts, model: "image-model", capability: "image", config, requirements, count: 1 })).toBe(3);
|
||||
expect(priceTiersForCurrentSelection(resolveModelChannel(config, config.model).modelCosts![0]!.logicalPriceTiers!, "image", config, requirements)[0]?.unitPriceMicrocredits).toBe(3_000_000);
|
||||
});
|
||||
test("preserves provider-specific resolution enums when matching price tiers", () => {
|
||||
expect(normalizeTierResolution("768P竖")).toBe("768p竖");
|
||||
expect(normalizeTierResolution("HD_Portrait")).toBe("hd_portrait");
|
||||
|
||||
32
web/test/skill-defaults.test.ts
Normal file
32
web/test/skill-defaults.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { DEFAULT_CANVAS_AGENT_SKILL_ID, ensureDefaultCanvasAgentSkill } from "@/services/skill-defaults";
|
||||
|
||||
function skill(skillId: string, isAdded = true) {
|
||||
return { skillId, skillName: skillId, isAdded } as never;
|
||||
}
|
||||
|
||||
describe("ensureDefaultCanvasAgentSkill", () => {
|
||||
test("keeps an already added default skill without writing", async () => {
|
||||
let added = 0;
|
||||
const result = await ensureDefaultCanvasAgentSkill({
|
||||
listAdded: async () => ({ skills: [skill(DEFAULT_CANVAS_AGENT_SKILL_ID)] }),
|
||||
get: async () => { throw new Error("must not read catalog"); },
|
||||
add: async () => { added += 1; return { skill: skill(DEFAULT_CANVAS_AGENT_SKILL_ID) }; },
|
||||
});
|
||||
expect(result.skills[0].skillId).toBe(DEFAULT_CANVAS_AGENT_SKILL_ID);
|
||||
expect(added).toBe(0);
|
||||
});
|
||||
|
||||
test("installs the default skill once when it is not added", async () => {
|
||||
let listCalls = 0;
|
||||
let added = 0;
|
||||
const result = await ensureDefaultCanvasAgentSkill({
|
||||
listAdded: async () => ({ skills: listCalls++ === 0 ? [] : [skill(DEFAULT_CANVAS_AGENT_SKILL_ID)] }),
|
||||
get: async () => ({ skill: skill(DEFAULT_CANVAS_AGENT_SKILL_ID, false) }),
|
||||
add: async () => { added += 1; return { skill: skill(DEFAULT_CANVAS_AGENT_SKILL_ID) }; },
|
||||
});
|
||||
expect(result.skills[0].skillId).toBe(DEFAULT_CANVAS_AGENT_SKILL_ID);
|
||||
expect(added).toBe(1);
|
||||
expect(listCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user