diff --git a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx index e69450100..e367a0586 100644 --- a/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-chat-ui.tsx @@ -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(); diff --git a/web/src/components/canvas/canvas-cloud-agent-panel.tsx b/web/src/components/canvas/canvas-cloud-agent-panel.tsx index 3d33ab7db..295407781 100644 --- a/web/src/components/canvas/canvas-cloud-agent-panel.tsx +++ b/web/src/components/canvas/canvas-cloud-agent-panel.tsx @@ -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); diff --git a/web/src/components/canvas/canvas-node-generation.ts b/web/src/components/canvas/canvas-node-generation.ts index 06e8d9c8b..503568795 100644 --- a/web/src/components/canvas/canvas-node-generation.ts +++ b/web/src/components/canvas/canvas-node-generation.ts @@ -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(); 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}】`, diff --git a/web/src/components/image-settings-panel.tsx b/web/src/components/image-settings-panel.tsx index adee71958..d68739288 100644 --- a/web/src/components/image-settings-panel.tsx +++ b/web/src/components/image-settings-panel.tsx @@ -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)[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, 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 }) { diff --git a/web/src/components/image-size-picker.css b/web/src/components/image-size-picker.css index c3d5ffa79..d2c66edf3 100644 --- a/web/src/components/image-size-picker.css +++ b/web/src/components/image-size-picker.css @@ -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; diff --git a/web/src/components/image-size-presets-editor.tsx b/web/src/components/image-size-presets-editor.tsx index 72d0929ae..2e933a069 100644 --- a/web/src/components/image-size-presets-editor.tsx +++ b/web/src/components/image-size-presets-editor.tsx @@ -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 ( - +
+ + {selected ? ( +
+ + + +
+ ) : null} +
); })} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index d8f5ca001..2cc6caef0 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -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) { + 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 { + quality = quality.trim().toLowerCase(); + size = size.trim().toLowerCase(); + const selector: Record = {}; + 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)[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; +} diff --git a/web/src/lib/image-quality.ts b/web/src/lib/image-quality.ts new file mode 100644 index 000000000..3c8d1c6f8 --- /dev/null +++ b/web/src/lib/image-quality.ts @@ -0,0 +1,45 @@ +const IMAGE_QUALITY_LABELS: Record = { + 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()); +} diff --git a/web/src/lib/model-pricing.ts b/web/src/lib/model-pricing.ts index d61e8fa14..a89d5e410 100644 --- a/web/src/lib/model-pricing.ts +++ b/web/src/lib/model-pricing.ts @@ -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; diff --git a/web/src/pages/admin/components/channel-model-cost-summary.tsx b/web/src/pages/admin/components/channel-model-cost-summary.tsx index f7464f31f..c10b15d8e 100644 --- a/web/src/pages/admin/components/channel-model-cost-summary.tsx +++ b/web/src/pages/admin/components/channel-model-cost-summary.tsx @@ -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} 秒` : "", diff --git a/web/src/pages/admin/components/channel-model-editor-form.ts b/web/src/pages/admin/components/channel-model-editor-form.ts index 552650fe7..583e8897e 100644 --- a/web/src/pages/admin/components/channel-model-editor-form.ts +++ b/web/src/pages/admin/components/channel-model-editor-form.ts @@ -113,7 +113,7 @@ export function validateChannelModelPrices(values: Pick 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 之间的有效数值"); diff --git a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx index 38d4fa207..1f7889bf4 100644 --- a/web/src/pages/admin/components/channel-model-price-tier-fields.tsx +++ b/web/src/pages/admin/components/channel-model-price-tier-fields.tsx @@ -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({ {String(ordinal).padStart(2, "0")}
{matchMode === "default" ? "默认价格" : `规格价格 ${ordinal}`}
-
{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、质量或尺寸匹配"}
+
{matchMode === "default" ? "所有请求使用同一价格" : isVideo ? "按生成方式与分辨率匹配" : "按生成方式、画质、分辨率或尺寸匹配"}
@@ -116,16 +116,19 @@ export function PriceTierFields({ ) : null} {isImage ? ( - - + + + ({ value, label: formatVideoResolutionLabel(value) }))} /> ) : imageProfile?.quality.supported && !imageResolutionUsesQuality(imageProfile) ? ( - ({ value, label: imageQualityLabel(value) }))} /> ) :
}
{generationCapability === "image" && imageProfile ? { setAspectRatio(size); if (quality) setImageQuality(quality); }} /> : null} diff --git a/web/src/services/api/auth.ts b/web/src/services/api/auth.ts index b60df7abc..2ef369cc8 100644 --- a/web/src/services/api/auth.ts +++ b/web/src/services/api/auth.ts @@ -439,7 +439,7 @@ export function getAdminFeatureAvailability() { } export function updateAdminFeatureAvailability( - features: Partial>, + features: Partial>, ) { return http.patch<{ features: FeatureAvailability }>("/admin/settings/features", features); } diff --git a/web/src/services/skill-defaults.ts b/web/src/services/skill-defaults.ts new file mode 100644 index 000000000..a5769f405 --- /dev/null +++ b/web/src/services/skill-defaults.ts @@ -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 { + 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(); +} diff --git a/web/src/services/user-data-sync.ts b/web/src/services/user-data-sync.ts index 39a7d5144..574cd6391 100644 --- a/web/src/services/user-data-sync.ts +++ b/web/src/services/user-data-sync.ts @@ -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 { +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 { const epoch = sessionEpoch; if (!activeRemoteUserId) throw new Error("尚未建立云端同步会话,请登录后重试"); requireRemoteUserDataBaseline(); @@ -765,11 +779,11 @@ export async function forceOverwriteRemoteCanvasSync(): Promise { 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 [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(); do { syncQueued = false; @@ -794,20 +808,37 @@ async function drainRemoteUserDataChanges(options: { force?: boolean; repairMiss } while (syncQueued); } -async function saveRemoteUserDataBatch(uploaded: Map, options: { force?: boolean; repairMissingResources?: boolean } = {}) { +async function saveRemoteUserDataBatch(uploaded: Map, 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(); @@ -1006,6 +1037,17 @@ async function saveRemoteUserDataBatch(uploaded: Map, 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(acknowledged: T | undefined, current: T) { function sanitizeCanvasProjectForRemoteSync(project: T): T { if (!project || typeof project !== "object") return project; - const clone = { ...(project as Record) }; + const { viewport: _viewport, ...clone } = project as Record; if (Array.isArray(clone.chatSessions)) { clone.chatSessions = clone.chatSessions.map((session) => { if (!session || typeof session !== "object") return session; diff --git a/web/src/stores/use-user-store.ts b/web/src/stores/use-user-store.ts index 892919e2f..a7e5f0375 100644 --- a/web/src/stores/use-user-store.ts +++ b/web/src/stores/use-user-store.ts @@ -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 = { diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 1ce725646..b5a783a22 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -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; diff --git a/web/test/canvas-character-generation.test.ts b/web/test/canvas-character-generation.test.ts index 2ad7ba5e2..67ca05236 100644 --- a/web/test/canvas-character-generation.test.ts +++ b/web/test/canvas-character-generation.test.ts @@ -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"], diff --git a/web/test/canvas-remote-revision.test.ts b/web/test/canvas-remote-revision.test.ts index 62426c281..1386ba70f 100644 --- a/web/test/canvas-remote-revision.test.ts +++ b/web/test/canvas-remote-revision.test.ts @@ -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 }); diff --git a/web/test/creation-gallery-gesture.test.ts b/web/test/creation-gallery-gesture.test.ts new file mode 100644 index 000000000..68ef02cdd --- /dev/null +++ b/web/test/creation-gallery-gesture.test.ts @@ -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]); + }); +}); diff --git a/web/test/image-size-presets.test.tsx b/web/test/image-size-presets.test.tsx index 003b16c29..d3c7d2e71 100644 --- a/web/test/image-size-presets.test.tsx +++ b/web/test/image-size-presets.test.tsx @@ -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); diff --git a/web/test/model-pricing.test.ts b/web/test/model-pricing.test.ts index 8982de438..6f4c1d765 100644 --- a/web/test/model-pricing.test.ts +++ b/web/test/model-pricing.test.ts @@ -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"); diff --git a/web/test/skill-defaults.test.ts b/web/test/skill-defaults.test.ts new file mode 100644 index 000000000..e0e0f7af6 --- /dev/null +++ b/web/test/skill-defaults.test.ts @@ -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); + }); +});