feat(*): 创作工作台 - 完善画布生成、模型定价与创建流程

This commit is contained in:
ddcat666
2026-10-10 04:40:20 +08:00
parent 508cad2b4e
commit 611949d6cc
39 changed files with 735 additions and 242 deletions

View File

@@ -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();

View File

@@ -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);

View File

@@ -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}】`,

View File

@@ -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 }) {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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) : "",

View 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;
}

View 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());
}

View File

@@ -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;

View File

@@ -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} 秒` : "",

View File

@@ -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 之间的有效数值");

View File

@@ -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="画幅/尺寸">

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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) =>

View File

@@ -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);

View File

@@ -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 : "未知错误"}`);

View 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;
};
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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"];

View File

@@ -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);

View File

@@ -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">

View File

@@ -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}

View File

@@ -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);
}

View 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();
}

View File

@@ -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;

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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"],

View File

@@ -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 });

View 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]);
});
});

View File

@@ -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);

View File

@@ -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");

View 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);
});
});