refactor(*): 平台架构 - 拆分后端领域模块并同步插件协议与模型能力

This commit is contained in:
ddCat
2026-09-11 14:02:49 +08:00
parent 916043fb5a
commit 6e5cbc8b05
644 changed files with 62731 additions and 2320 deletions

View File

@@ -23,7 +23,7 @@ import {
} from "@/lib/canvas/camera-prompt-library";
const defaultCameraControl: CameraControlOptions = {
enabled: true,
enabled: false,
camera: "arri_alexa_mini_lf",
lens: "arri_signature_prime",
focalLength: 50,
@@ -232,7 +232,7 @@ interface CardColumnProps {
const CardColumn = memo(({ label, tooltipTitle, tooltipDesc, tooltipUseCase, visual, cornerBadge, captionBelow, onPrev, onNext, theme }: CardColumnProps) => {
return (
<div className="flex flex-1 flex-col items-center gap-1.5">
<div className="flex min-w-0 flex-col items-center gap-1.5">
<button
type="button"
onClick={onPrev}
@@ -244,7 +244,7 @@ const CardColumn = memo(({ label, tooltipTitle, tooltipDesc, tooltipUseCase, vis
</button>
<HoverTip title={tooltipTitle} description={tooltipDesc} useCase={tooltipUseCase}>
<div
className="relative flex h-[140px] w-[140px] cursor-help flex-col items-center justify-between rounded-xl border px-3 pt-2 pb-2 transition-colors"
className="relative flex h-[clamp(116px,14vw,140px)] w-full min-w-0 cursor-help flex-col items-center justify-between rounded-xl border px-2.5 pt-2 pb-2 transition-colors"
style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}
>
<span className="text-[11px] font-medium tracking-wide" style={{ color: theme.node.muted }}>{label}</span>
@@ -286,7 +286,7 @@ export function CanvasNodeCameraPanel({
const reducedMotion = useReducedMotion();
const copyText = useCopyText();
const [enabled, setEnabled] = useState(cameraControl?.enabled ?? true);
const [enabled, setEnabled] = useState(cameraControl?.enabled ?? defaultCameraControl.enabled);
const [cameraIdx, setCameraIdx] = useState(() => {
const i = CAMERA_PROFILES.findIndex((c) => c.id === cameraControl?.camera);
return i >= 0 ? i : 0;
@@ -350,16 +350,30 @@ export function CanvasNodeCameraPanel({
initial={reducedMotion ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.panel}
className="w-full overflow-hidden rounded-[var(--r-2xl)] border backdrop-blur-2xl"
className="w-full overflow-hidden rounded-[var(--r-xl)] border backdrop-blur-2xl"
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text, boxShadow: `0 28px 80px ${theme.spatial.shadow}` }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{/* 标题区由外层 AppModal 统一提供,面板只负责镜头参数。 */}
<div className="flex items-center justify-between gap-3 border-b px-4 py-3" style={{ borderColor: theme.toolbar.border }}>
<div className="min-w-0">
<div className="text-sm font-semibold tracking-tight">摄像机控制</div>
<div className="mt-0.5 truncate text-[11px]" style={{ color: theme.node.muted }}>为当前图片生成设置镜头、焦距与光圈</div>
</div>
<button
type="button"
className="grid size-7 shrink-0 place-items-center rounded-md transition-colors hover:bg-white/10"
style={{ color: theme.node.muted }}
onClick={onClose}
aria-label="关闭摄像机控制"
title="关闭"
>
<X className="size-4" />
</button>
</div>
{/* 相机、镜头、焦距和光圈均来自已注册枚举,确认时由纯函数再次强校验。 */}
<div className="flex justify-center gap-3 px-5 py-5">
<div className="grid grid-cols-2 gap-2.5 px-4 py-4 sm:grid-cols-4">
<CardColumn
label="相机"
tooltipTitle={`${currentCamera.zhName} · ${currentCamera.label}`}
@@ -421,13 +435,13 @@ export function CanvasNodeCameraPanel({
</div>
{/* 当前有效配置摘要。 */}
<div className="mx-5 mb-3 rounded-lg border px-3.5 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}>
<div className="mx-4 mb-3 rounded-lg border px-3.5 py-2.5" style={{ borderColor: theme.toolbar.border, background: theme.toolbar.itemHover }}>
<div className="text-[10px] uppercase tracking-wide" style={{ color: theme.node.faint }}>当前配置</div>
<div className="mt-0.5 text-xs">{summary}</div>
</div>
{/* 操作区:重置与复制不产生写入,只有应用会提交生成配置。 */}
<div className="flex h-12 items-center gap-2 border-t px-5" style={{ borderColor: theme.toolbar.border }}>
<div className="flex min-h-12 flex-wrap items-center gap-2 border-t px-4 py-2" style={{ borderColor: theme.toolbar.border }}>
<button type="button" className={secondaryButtonClass} style={{ color: theme.node.muted }} onClick={handleReset}>
<RotateCcw className="size-3.5" />重置参数
</button>
@@ -445,18 +459,24 @@ export function CanvasNodeCameraPanel({
role="switch"
aria-checked={enabled}
onClick={() => setEnabled((v) => !v)}
className="relative inline-flex h-6 w-11 cursor-pointer items-center rounded-full transition-colors"
style={{ background: enabled ? theme.node.activeStroke : theme.toolbar.itemHover }}
className="canvas-node-camera-switch relative inline-flex h-5 w-9 cursor-pointer items-center rounded-full border transition-colors"
style={{
background: enabled ? `${theme.node.activeStroke}22` : theme.toolbar.itemHover,
borderColor: enabled ? theme.node.activeStroke : theme.toolbar.border,
}}
>
<span className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${enabled ? "translate-x-[22px]" : "translate-x-0.5"}`} />
<span
className={`inline-block size-3.5 transform rounded-full shadow-sm transition-transform ${enabled ? "translate-x-[18px]" : "translate-x-0.5"}`}
style={{ background: enabled ? theme.node.activeStroke : theme.node.muted }}
/>
</button>
</div>
<motion.button
type="button"
whileHover={reducedMotion ? undefined : { y: -1 }}
whileTap={reducedMotion ? undefined : { scale: 0.97 }}
className="flex h-8 items-center gap-1.5 rounded-[var(--dock-item-radius)] px-4 text-[var(--fs-label)] font-semibold"
style={{ background: theme.node.activeStroke, color: theme.node.panel }}
className="canvas-node-camera-apply flex h-7 items-center gap-1.5 rounded-[var(--dock-item-radius)] border px-3.5 text-[var(--fs-label)] font-semibold transition-colors"
style={{ borderColor: theme.node.activeStroke, color: theme.node.activeStroke, background: "transparent" }}
onClick={handleApply}
>
<CameraIcon className="size-3.5" />应用

View File

@@ -14,7 +14,6 @@ import { modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefa
import { navigateToSettings } from "@/lib/settings-navigation";
import { useThemeStore } from "@/stores/use-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { AppModal } from "@/components/ui/product/app-modal/app-modal";
import type { CameraControlOptions } from "@/lib/canvas/camera-prompt-library";
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
import { CanvasNodeCameraPanel } from "./canvas-node-camera-dialog";
@@ -54,7 +53,8 @@ type CanvasNodePromptPanelProps = {
type CanvasTheme = (typeof canvasThemes)[keyof typeof canvasThemes];
const PROMPT_REFERENCE_SHELF_HEIGHT = 58;
const PROMPT_EDITOR_MIN_HEIGHT = 44;
// Keep the compact editor readable at rest: three 20px lines plus 12px vertical padding.
const PROMPT_EDITOR_MIN_HEIGHT = 72;
const PROMPT_EDITOR_EXPANDED_MIN_HEIGHT = 76;
const PROMPT_EDITOR_LINE_HEIGHT = 20;
const PROMPT_EDITOR_EXPANDED_LINE_HEIGHT = 24;
@@ -83,6 +83,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
const [manualPromptHeight, setManualPromptHeight] = useState<number | null>(null);
const [manualExpandedPromptHeight, setManualExpandedPromptHeight] = useState<number | null>(null);
const [paramsExpanded, setParamsExpanded] = useState(false); // #98 决策2:B区参数区折叠状态(手风琴)
const [cameraPanelOpen, setCameraPanelOpen] = useState(false);
const [promptOptimizerOpen, setPromptOptimizerOpen] = useState(false);
const [autoLinkEnabled, setAutoLinkEnabled] = useState(true);
const resolvedMentionReferences = useResolvedCanvasResourceReferences(mentionReferences, { projectId });
@@ -180,6 +181,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
setExpandedPromptContentHeight(estimatePromptContentHeight(normalizedSavedPrompt, true));
setManualPromptHeight(null);
setManualExpandedPromptHeight(null);
setCameraPanelOpen(false);
}, [node.id]);
useEffect(() => {
@@ -401,9 +403,10 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
<>
<CameraToolsPopover
cameraControl={node.metadata?.cameraControl}
onCameraControlChange={(options) => onConfigChange(node.id, { cameraControl: options })}
theme={theme}
compact={!expanded}
open={cameraPanelOpen}
onOpenChange={setCameraPanelOpen}
/>
<CanvasImageSettingsPopover
config={config}
@@ -471,6 +474,19 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
);
};
const renderCameraPanel = () => (
<div className="canvas-node-composer-camera-panel">
<CanvasNodeCameraPanel
cameraControl={node.metadata?.cameraControl}
onClose={() => setCameraPanelOpen(false)}
onConfirm={(options) => {
onConfigChange(node.id, { cameraControl: options });
setCameraPanelOpen(false);
}}
/>
</div>
);
return (
<CanvasPromptOptimizerDrawer
open={promptOptimizerOpen}
@@ -486,7 +502,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
onApply={(nextPrompt) => updatePrompt(nextPrompt)}
>
<div
className="canvas-node-composer"
className={`canvas-node-composer${cameraPanelOpen ? " has-camera-panel" : ""}`}
style={composerSurfaceStyle}
onMouseDown={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
@@ -495,6 +511,7 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
{renderComposerHeader(false)}
{renderPromptEditor(false)}
{cameraPanelOpen ? renderCameraPanel() : null}
{/* B区 参数区(对应 #98 决策2:默认折叠,手风琴展开)*/}
{hasVideoPromptTools ? (
@@ -538,9 +555,10 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
body: { minHeight: 0, padding: 0 },
}}
>
<div className="flex min-h-0 flex-col gap-2.5 p-3" style={{ ...composerTokens, color: theme.node.text }}>
<div className="relative flex min-h-0 flex-col gap-2.5 overflow-visible p-3" style={{ ...composerTokens, color: theme.node.text }}>
<div className="shrink-0 pr-8">{renderComposerHeader(true)}</div>
{renderPromptEditor(true)}
{cameraPanelOpen ? renderCameraPanel() : null}
{hasVideoPromptTools ? (
<div className="canvas-node-composer-parameters shrink-0">
<CanvasVideoPromptTools metadata={node.metadata} frameOptions={videoFrameOptions} onMetadataChange={(patch) => onConfigChange(node.id, patch)} />
@@ -555,39 +573,26 @@ export function CanvasNodePromptPanel({ projectId, node, isRunning, onPromptChan
);
}
function CameraToolsPopover({ cameraControl, onCameraControlChange, theme, compact }: { cameraControl?: CameraControlOptions; onCameraControlChange: (options: CameraControlOptions) => void; theme: CanvasTheme; compact: boolean }) {
const [open, setOpen] = useState(false);
function CameraToolsPopover({ cameraControl, theme, compact, open, onOpenChange }: { cameraControl?: CameraControlOptions; theme: CanvasTheme; compact: boolean; open: boolean; onOpenChange: (open: boolean) => void }) {
const cameraEnabled = cameraControl?.enabled === true;
const active = open || cameraEnabled;
return (
<>
<button
type="button"
className={`canvas-node-composer-camera-tools-trigger inline-flex shrink-0 items-center gap-1 rounded-full px-1.5 text-[var(--fs-tiny)] transition-colors ${compact ? "is-compact h-7" : "h-8"}`}
style={{
background: cameraEnabled ? `${theme.node.activeStroke}66` : theme.node.fill,
color: cameraEnabled ? theme.node.panel : theme.node.text,
}}
aria-pressed={cameraEnabled}
aria-label="摄像机控制"
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
onClick={() => setOpen(true)}
>
<Camera className="size-4.5" />
{!compact ? <span>摄像机</span> : null}
</button>
{open ? (
<AppModal title="摄像机控制" open centered footer={null} width={780} flush onCancel={() => setOpen(false)}>
<CanvasNodeCameraPanel
cameraControl={cameraControl}
onClose={() => setOpen(false)}
onConfirm={(options) => {
onCameraControlChange(options);
setOpen(false);
}}
/>
</AppModal>
) : null}
</>
<button
type="button"
className={`canvas-node-composer-camera-tools-trigger inline-flex shrink-0 items-center gap-1 rounded-[var(--r-md)] border px-1.5 text-[var(--fs-tiny)] transition-colors focus:outline-none focus-visible:outline-none ${compact ? "is-compact h-7" : "h-8"}`}
style={{
background: active ? `${theme.node.activeStroke}14` : theme.node.fill,
borderColor: active ? theme.node.activeStroke : "transparent",
color: active ? theme.node.activeStroke : theme.node.text,
}}
aria-pressed={active}
aria-label="摄像机控制"
title={`摄像机控制${cameraEnabled ? " · 已启用" : ""}`}
onClick={() => onOpenChange(!open)}
>
<Camera className="size-4.5" />
{!compact ? <span>摄像机</span> : null}
</button>
);
}
@@ -599,30 +604,31 @@ function ReferenceToolsPopover({ canAutoMention, autoLinkEnabled, onAutoMention,
rootClassName="canvas-reference-tools-popover"
arrow={false}
align={{ offset: [0, -8] }}
styles={{ root: { width: "min(280px, calc(100vw - 24px))" }, container: { width: "100%" }, content: { width: "100%", padding: 12 } }}
styles={{ root: { width: "min(280px, calc(100vw - 24px))" }, container: { width: "100%" }, content: { width: "100%", padding: 10 } }}
content={
<div className="space-y-3">
<div className="space-y-1.5">
<div>
<div className="text-sm font-medium">智能引用</div>
<div className="mt-1 text-xs text-black/50 dark:text-white/50">输入素材序号或名称后按 Tab,可快速引用</div>
<div className="text-sm font-medium leading-5">智能引用</div>
<div className="mt-0.5 text-xs leading-4 text-black/50 dark:text-white/50">输入素材序号或名称后按 Tab,可快速引用</div>
</div>
<div className="flex items-center justify-between gap-3">
<div className="flex min-h-6 items-center justify-between gap-3">
<div className="flex items-center gap-2 text-sm"><Link2 className="size-3.5" />AutoLink</div>
<button
type="button"
role="switch"
aria-checked={autoLinkEnabled}
aria-label={autoLinkEnabled ? "关闭 AutoLink" : "开启 AutoLink"}
className={`relative h-5 w-9 rounded-full transition-colors ${autoLinkEnabled ? "" : "bg-black/20 dark:bg-white/20"}`}
style={autoLinkEnabled ? { background: accent } : undefined}
className="canvas-reference-autolink-switch relative inline-flex h-5 w-9 items-center rounded-full border transition-colors"
style={{ background: autoLinkEnabled ? `${accent}14` : "transparent", borderColor: autoLinkEnabled ? accent : "color-mix(in srgb, currentColor 22%, transparent)", color: accent }}
onClick={() => onAutoLinkEnabledChange(!autoLinkEnabled)}
>
<span className={`absolute left-0.5 top-0.5 size-4 rounded-full bg-white shadow-sm transition-transform ${autoLinkEnabled ? "translate-x-4" : "translate-x-0"}`} />
<span className={`size-3.5 rounded-full shadow-sm transition-transform ${autoLinkEnabled ? "translate-x-[18px]" : "translate-x-0.5"}`} style={{ background: autoLinkEnabled ? accent : "currentColor" }} />
</button>
</div>
<button
type="button"
className="flex w-full items-center justify-center gap-1.5 rounded-md bg-[var(--canvas-composer-control-surface)] px-2 py-1.5 text-sm transition-colors hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-45"
className="canvas-reference-tools-mention-button flex h-7 w-full items-center justify-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-45"
style={{ borderColor: accent, color: accent, background: "transparent" }}
disabled={!canAutoMention}
onClick={onAutoMention}
>

View File

@@ -352,7 +352,7 @@ export const CanvasNode = React.memo(function CanvasNode({
}}
>
<div
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script ? "overflow-visible" : "overflow-hidden"}`}
className={`relative flex h-full w-full items-center justify-center rounded-[inherit] ${isBatchRoot || data.type === CanvasNodeType.Script || isComposerNode ? "overflow-visible" : "overflow-hidden"}`}
style={
{
background: hasImageContent || hasVideoContent || hasAudioContent ? "transparent" : theme.node.fill,

View File

@@ -87,7 +87,7 @@ export function CanvasSelectionToolbar({ anchorRef, containerRef, count, childre
);
}
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, dragOffset, isDragging = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; children: ReactNode }) {
export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidth, panelHeight = 190, dragOffset, isDragging = false, allowOverflow = false, children }: { node: CanvasNodeData; viewport: ViewportTransform; containerRef: RefObject<HTMLDivElement | null>; panelWidth?: number; panelHeight?: number; dragOffset?: Position | null; isDragging?: boolean; allowOverflow?: boolean; children: ReactNode }) {
const panelRef = useRef<HTMLDivElement>(null);
const { bringToFront, zIndex } = useCanvasOverlayLayer(`node-panel:${node.id}`, "var(--z-modal-overlay)");
const initialWidth = resolveNodePanelWidth(node, viewport, panelWidth);
@@ -135,8 +135,8 @@ export function CanvasNodePanelOverlay({ node, viewport, containerRef, panelWidt
ref={panelRef}
data-canvas-no-zoom
data-canvas-node-panel
className="thin-scrollbar absolute max-w-[calc(100%_-_24px)] overflow-y-auto"
style={{ left: 0, top: 0, transform: `translate3d(${initialPosition.left}px, ${initialPosition.top}px, 0)`, width: initialWidth, maxHeight: "calc(100% - 84px)", zIndex }}
className={`thin-scrollbar absolute max-w-[calc(100%_-_24px)] ${allowOverflow ? "overflow-visible" : "overflow-y-auto"}`}
style={{ left: 0, top: 0, transform: `translate3d(${initialPosition.left}px, ${initialPosition.top}px, 0)`, width: initialWidth, maxHeight: allowOverflow ? "none" : "calc(100% - 84px)", zIndex }}
onMouseDown={(event) => event.stopPropagation()}
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}

View File

@@ -60,7 +60,11 @@ export function useResolvedCanvasResourceReferences(references: CanvasResourceRe
}
function previewIdentity(reference: CanvasResourceReference, projectId?: string) {
if (reference.drawingId && projectId) return `drawing:${projectId}:${reference.drawingId}:${reference.drawingRevision || 0}`;
// 只有当 drawingId 和 projectId 同时存在时才返回绘图身份,避免不完整的缓存键导致运行时错误
if (reference.drawingId) {
if (!projectId) return "";
return `drawing:${projectId}:${reference.drawingId}:${reference.drawingRevision || 0}`;
}
const storageKey = reference.kind === "video" ? reference.previewStorageKey : reference.storageKey;
if (!storageKey || !["image", "video", "character"].includes(reference.kind)) return "";
return `${reference.kind}:${storageKey}`;

View File

@@ -2,7 +2,7 @@ import { useState } from "react";
import "./image-size-picker.css";
import { Input, Button } from "antd";
import type { ImageCapabilityConfig } from "@/lib/model-capabilities";
import { IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { IMAGE_RESOLUTIONS, imagePresetForRatio, imagePresetValue, imageQualityForSelection, imageQualityForTier, imageResolutionUsesQuality, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { buildImageResolutionOptions, type ImageResolutionOption, type ImageResolutionTier } from "@/lib/image-resolution-tiers";
import { resolveImageRequestSize, validateImageSize } from "@/services/api/image-validation";
@@ -14,8 +14,13 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
const [customPreset, setCustomPreset] = useState<ImageResolutionOption>();
const [custom, setCustom] = useState("");
const [error, setError] = useState("");
const matches = (preset: ImageResolutionOption) =>
imagePresetValue(profile, preset) === size && (profile.size.parameter !== "aspect_ratio" || !imageResolutionUsesQuality(profile) || imageQualityForTier(profile, preset.tier) === (quality || profile.quality.default));
const qualityTier = IMAGE_RESOLUTIONS.find((value) => value === (quality || "").toLowerCase());
const matches = (preset: ImageResolutionOption) => {
if (imagePresetValue(profile, preset) !== size) return false;
if (profile.size.parameter !== "aspect_ratio") return true;
if (imageResolutionUsesQuality(profile)) return imageQualityForTier(profile, preset.tier) === (quality || profile.quality.default);
return !qualityTier || qualityTier === preset.tier;
};
let resolvedLegacySize: string | undefined;
if (profile.size.parameter === "size" && size.includes(":")) {
try {
@@ -29,7 +34,9 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
try {
if (profile.size.parameter === "size") restoredCustom = buildImageResolutionOptions([resolvedLegacySize || size])[0];
else if (profile.size.parameter === "aspect_ratio") {
const restoredTier = IMAGE_RESOLUTIONS.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) || "1k";
const restoredTier = imageResolutionUsesQuality(profile)
? IMAGE_RESOLUTIONS.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) || "1k"
: (qualityTier && visibleTiers.includes(qualityTier) ? qualityTier : visibleTiers[0]) || "1k";
restoredCustom = imagePresetForRatio(restoredTier, size);
}
} catch {
@@ -44,7 +51,8 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
const available = (value: ImageResolutionTier) => imageTierAvailable(profile, value) && (profile.size.allowCustom || presets.some((preset) => preset.tier === value));
const tier =
(active && visibleTiers.includes(active.tier) ? active.tier : undefined) ||
(profile.size.parameter === "aspect_ratio" ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) ||
(profile.size.parameter === "aspect_ratio" && imageResolutionUsesQuality(profile) ? visibleTiers.find((value) => imageQualityForTier(profile, value) === (quality || profile.quality.default)) : undefined) ||
(qualityTier && visibleTiers.includes(qualityTier) ? qualityTier : undefined) ||
(visibleTiers.includes(chosenTier) ? chosenTier : visibleTiers[0]) ||
"1k";
const choices = presets.filter((preset) => preset.tier === tier);
@@ -53,7 +61,7 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
setChosenTier(preset.tier);
setCustomPreset(preset);
setError("");
onChange(imagePresetValue(profile, preset), profile.size.parameter === "aspect_ratio" ? imageQualityForTier(profile, preset.tier) : undefined);
onChange(imagePresetValue(profile, preset), profile.size.parameter === "aspect_ratio" ? imageQualityForSelection(profile, preset.tier) : undefined);
};
const makeCustom = (next: ImageResolutionTier, ratio: string) => {
const preset = imagePresetForRatio(next, ratio);
@@ -154,7 +162,7 @@ export function ImageSizePicker({ profile, size, quality, onChange }: { profile:
<output aria-live="polite" className="image-size-output">
{size === "auto" ? "尺寸由模型决定" : active ? `${active.ratio} · ${active.width} × ${active.height} px` : size.replace("x", " × ")}
</output>
{profile.size.parameter === "aspect_ratio" ? <p className="image-size-hint">尺寸为换算参考,实际像素由模型决定{!imageResolutionUsesQuality(profile) ? ";当前协议未配置独立分辨率" : ""}。</p> : null}
{profile.size.parameter === "aspect_ratio" ? <p className="image-size-hint">尺寸为换算参考,实际像素由模型决定{!imageResolutionUsesQuality(profile) && visibleTiers.length <= 1 ? ";当前协议未配置独立分辨率" : ""}。</p> : null}
{profile.size.allowCustom ? (
<details className="image-size-custom">
<summary>自定义比例或尺寸</summary>

View File

@@ -4,7 +4,7 @@ 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, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import { IMAGE_RATIOS, IMAGE_RESOLUTIONS, imagePresetForRatio, imageResolutionUsesQuality, imageSizeConfigWithPresets, imageSizePresets, imageTierAvailable } from "@/lib/image-size-presets";
import type { ImageResolutionTier } from "@/lib/image-resolution-tiers";
export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profile: ImageCapabilityConfig; disabled?: boolean; onChange: (size: ImageCapabilityConfig["size"]) => void }) {
@@ -62,7 +62,7 @@ export function ImageSizePresetsEditor({ profile, disabled, onChange }: { profil
</div>
</header>
<div className="image-size-preset-body">
{!imageTierAvailable(profile, tier) ? <p className="image-size-hint">需先配置此档位对应的质量值,用户端才会开放。</p> : null}
{imageResolutionUsesQuality(profile) && !imageTierAvailable(profile, tier) ? <p className="image-size-hint">需先配置此档位对应的质量值,用户端才会开放。</p> : null}
<div className="image-size-preset-options" role="group" aria-label={`${tier.toUpperCase()} 支持的宽高比`}>
{ratios.map((ratio) => {
const selected = items.find((item) => item.ratio === ratio);

View File

@@ -61,17 +61,29 @@ export function imageResolutionUsesQuality(profile: ImageCapabilityConfig) {
}
export function imageTierAvailable(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
// 比例协议必须能把档位传给上游;允许自定义比例不等于支持任意分辨率。
// 质量参数能映射档位时按上游质量裁剪;管理员尺寸预设存在时再取交集,避免默认 low/medium/high 把未启用的 1K 显示出来。
if (profile.size.parameter !== "aspect_ratio") return profile.size.parameter !== "none";
return imageResolutionUsesQuality(profile) ? Boolean(imageQualityForTier(profile, tier)) : tier === "1k";
const configuredTiers = new Set(profile.size.presets?.map((preset) => preset.tier));
if (imageResolutionUsesQuality(profile)) {
if (!imageQualityForTier(profile, tier)) return false;
return configuredTiers.size ? configuredTiers.has(tier) : true;
}
if (configuredTiers.size) return configuredTiers.has(tier);
// 无质量映射且无预设时,后端 filterImageSizePresets 会返回全部预设或空;
// 前端不应硬编码 1k 兜底,避免产生后端不认可的幻影选项。
return false;
}
export function imageQualityForSelection(profile: ImageCapabilityConfig, tier: ImageResolutionTier) {
return imageQualityForTier(profile, tier) || (!imageResolutionUsesQuality(profile) && imageTierAvailable(profile, tier) ? tier : undefined);
}
export function imageSizePresets(profile: ImageCapabilityConfig): ImageResolutionOption[] {
if (profile.size.parameter === "none") return [];
if (profile.size.presets) return profile.size.presets;
const pixels = buildImageResolutionOptions(profile.size.values);
const tiers = profile.size.parameter === "aspect_ratio" ? IMAGE_RESOLUTIONS.filter((tier) => imageQualityForTier(profile, tier)) : ["1k" as const];
const ratios = (tiers.length ? tiers : ["1k" as const]).flatMap((tier) =>
const tiers = profile.size.parameter === "aspect_ratio" ? IMAGE_RESOLUTIONS.filter((tier) => imageQualityForTier(profile, tier)) : [];
const ratios = tiers.flatMap((tier) =>
profile.size.values.flatMap((ratio) => {
try {
const preset = imagePresetForRatio(tier, ratio);

View File

@@ -1,5 +1,5 @@
import type { ModelProtocol, ModelProtocolWorkflow } from "@/lib/model-protocols";
import type { ImageResolutionOption } from "@/lib/image-resolution-tiers";
import type { ImageResolutionOption, ImageResolutionTier } from "@/lib/image-resolution-tiers";
export type ModelCapabilityConfig = {
version: number;
@@ -897,18 +897,29 @@ function matchWorkflowValue(value: string, options: string[]) {
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
const size = normalizeImageSizeSetting(profile, value.size);
const requestedQuality = String(value.quality || "").trim().toLowerCase();
// 比例协议的固定分辨率预设没有独立 quality 字段时,UI 仍需把当前比例对应的
// 预设档位带入请求。仅在 quality 未声明支持时启用,避免与 auto/low/medium/high
// 这组真实图片质量语义混用。
const presetTier = !profile.quality.supported ? imagePresetTierForSelection(profile, size) : undefined;
const quality = profile.quality.supported
? requestedQuality === "auto" || requestedQuality === "any"
? "auto"
: value.quality && profile.quality.values.includes(value.quality)
? value.quality
: profile.quality.default || "auto"
: profile.quality.default || "auto";
: requestedQuality === "1k" || requestedQuality === "2k" || requestedQuality === "4k"
? requestedQuality
: presetTier || profile.quality.default || "auto";
const count = String(Math.max(1, Math.min(profile.maxOutputs, Math.floor(Math.abs(Number(value.count)) || 1))));
const transparentBackground = profile.transparentBackground.supported && value.transparentBackground === "true" ? "true" : "false";
return { size, quality, count, transparentBackground };
}
function imagePresetTierForSelection(profile: ImageCapabilityConfig, size: string): ImageResolutionTier | undefined {
if (profile.size.parameter !== "aspect_ratio" || !size || size === "auto") return undefined;
return profile.size.presets?.find((preset) => preset.ratio === size)?.tier;
}
export function normalizeImageSizeSetting(profile: ImageCapabilityConfig, value?: string) {
if (profile.size.parameter === "none") return "auto";
const candidate = value?.trim() || profile.size.default;

View File

@@ -9,6 +9,7 @@ import { ASSET_STORE_KEY, flushAssetStorePersistence, useAssetStore } from "@/st
import { CONFIG_STORE_KEY, PUBLIC_MODEL_CATALOG_ID, defaultConfig, normalizeConfigSnapshot, useConfigStore, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
import { CREATION_PREFERENCES_STORE_KEY, useCreationPreferencesStore } from "@/stores/use-creation-preferences-store";
import { defaultModelCapabilityConfig, STANDARD_IMAGE_SIZE_VALUES, type ModelCapabilityConfig } from "@/lib/model-capabilities";
import { imageSizeConfigWithPresets } from "@/lib/image-size-presets";
import { useUserStore } from "@/stores/use-user-store";
import { PLUGIN_STORE_KEY, usePluginStore } from "@/stores/use-plugin-store";
import { initializeRemoteUserDataSession, installRemoteUserDataAutoSync, resetRemoteUserDataSync, withRemoteUserDataSyncExclusive } from "@/services/user-data-sync";
@@ -198,7 +199,7 @@ export function systemChannelModelChannels(channels: PublicChannelCatalog[]): Mo
});
}
function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string, unknown>): ModelCapabilityConfig {
export function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string, unknown>): ModelCapabilityConfig {
const projected = defaultModelCapabilityConfig();
if (spec.capability === "image" && projected.image) {
projected.image.references.maxImages = spec.inputs?.image?.max ?? 0;
@@ -208,11 +209,13 @@ function projectLogicalCapability(spec: CapabilitySpec, defaults: Record<string,
projected.image.transparentBackground = { supported: false, default: false };
const sizeOption = spec.options?.size || spec.options?.aspectRatio;
const sizeValues = stringValues(sizeOption);
const sizeAllowsCustom = sizeValues.includes("*");
const sizeAllowsCustom = sizeValues.includes("*") || Boolean(spec.imageSize?.allowCustom);
const concreteSizeValues = sizeValues.filter((value) => value !== "*");
const sizePresets = concreteSizeValues.length ? concreteSizeValues : sizeAllowsCustom ? [...STANDARD_IMAGE_SIZE_VALUES] : [];
if (sizePresets.length || sizeAllowsCustom) {
projected.image!.size = { parameter: "size", values: sizePresets, default: concreteDefault(defaults.size, sizePresets, "1:1"), allowCustom: sizeAllowsCustom };
if (sizePresets.length || sizeAllowsCustom || spec.imageSize?.presets?.length) {
const parameter = spec.imageSize?.parameter === "aspect_ratio" || spec.imageSize?.parameter === "size" ? spec.imageSize.parameter : "size";
projected.image.size = { parameter, values: sizePresets, default: concreteDefault(defaults.size, sizePresets, "1:1"), allowCustom: sizeAllowsCustom };
if (spec.imageSize?.presets?.length) projected.image.size = imageSizeConfigWithPresets(projected.image, spec.imageSize.presets);
}
applyStringOption(spec.options?.quality, defaults.quality, (values, initial) => {
projected.image!.quality = { supported: true, values, default: initial };

View File

@@ -49,7 +49,7 @@ export function ApiLogDetailDrawer({ logId, onClose, onLogUpdated }: { logId: st
};
return (
<Drawer title="请求详情" open={Boolean(logId)} onClose={onClose} size="min(920px, 100vw)" destroyOnHidden rootClassName="admin-drawer">
<Drawer title="请求详情" open={Boolean(logId)} onClose={onClose} width="min(1200px, 90vw)" destroyOnHidden rootClassName="admin-drawer">
{loading ? <Skeleton active paragraph={{ rows: 12 }} /> : log ? <LogDetail log={log} querying={querying} onQueryProviderTask={queryProviderTask} /> : <EmptyState size="compact" title="没有请求详情" />}
</Drawer>
);
@@ -64,27 +64,64 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
["状态", <AdminStatusBadge label={failed ? "失败" : processing ? "处理中" : "成功"} tone={failed ? "error" : processing ? "warning" : "success"} />],
[
"用户",
<span>
{log.userDisplayName || log.userAccount || "未知用户"}
{log.userAccount ? <span className="ml-2 text-foreground/45">@{log.userAccount}</span> : null}
</span>,
<div>
<div className="font-medium text-foreground/90">{log.userDisplayName || log.userAccount || "未知用户"}</div>
{log.userAccount ? <div className="mt-0.5 text-xs text-foreground/50">@{log.userAccount}</div> : null}
</div>,
],
[
"渠道 / 模型",
<div>
<div className="text-foreground/85">{log.channelName || "未记录渠道"}</div>
<div className="mt-0.5 font-mono text-xs text-foreground/55" title={log.model}>
{log.model || "未识别模型"}
</div>
</div>,
],
["渠道 / 模型", `${log.channelName || "未记录渠道"} / ${log.model || "未识别模型"}`],
["能力", capabilityText(log.capability)],
["请求阶段", requestKindText(log.requestKind)],
["计费属性", log.billable ? "计费调用" : "不计费"],
["总耗时", formatDuration(log.durationMs)],
["视频轮询", log.capability === "video" ? `${log.pollCount || 0} 次` : "--"],
["Token", log.usageAvailable ? `${log.inputTokens} 输入 / ${log.outputTokens} 输出 / ${log.cachedTokens} 缓存` : "未返回"],
["总耗时", <span className="tabular-nums">{formatDuration(log.durationMs)}</span>],
["视频轮询", log.capability === "video" ? <span className="tabular-nums">{log.pollCount || 0} 次</span> : <span className="text-foreground/35">--</span>],
[
"Token 用量",
log.usageAvailable ? (
<div className="space-y-1 font-mono text-sm tabular-nums">
<div>
<span className="text-foreground/55">输入</span> <span className="ml-2 text-foreground/85">{log.inputTokens.toLocaleString()}</span>
</div>
<div>
<span className="text-foreground/55">输出</span> <span className="ml-2 text-foreground/85">{log.outputTokens.toLocaleString()}</span>
</div>
{log.cachedTokens > 0 ? (
<div>
<span className="text-foreground/55">缓存</span> <span className="ml-2 text-foreground/85">{log.cachedTokens.toLocaleString()}</span>
</div>
) : null}
</div>
) : (
<span className="text-foreground/35">未返回</span>
),
],
["积分计费", billingText(log)],
["上游成本", log.costAvailable ? `${log.currency || "USD"} ${(log.estimatedCostMicros / 1_000_000).toFixed(6)}` : "未配置成本"],
["错误信息", [log.errorCode, log.error].filter(Boolean).join(" · ") || "--"],
["方法与路径", `${log.method} ${log.path}`],
["请求 Content-Type", log.requestContentType || "--"],
["HTTP 状态", String(log.statusCode || "--")],
["任务 ID", log.taskId || "--"],
["供应商任务 ID", log.providerRequestId || "--"],
["上游地址", log.upstreamUrl || "--"],
["上游成本", log.costAvailable ? <span className="font-mono tabular-nums">{log.currency || "USD"} {(log.estimatedCostMicros / 1_000_000).toFixed(6)}</span> : <span className="text-foreground/35">未配置成本</span>],
[
"错误信息",
[log.errorCode, log.error].filter(Boolean).length > 0 ? (
<div>
{log.errorCode ? <div className="mb-1 font-mono text-sm font-medium text-red-500">{log.errorCode}</div> : null}
{log.error ? <div className="text-sm text-foreground/75">{log.error}</div> : null}
</div>
) : (
<span className="text-foreground/35">--</span>
),
],
["方法与路径", <code className="text-sm text-foreground/80">{log.method} {log.path}</code>],
["请求 Content-Type", log.requestContentType ? <code className="text-sm text-foreground/70">{log.requestContentType}</code> : <span className="text-foreground/35">--</span>],
["HTTP 状态", log.statusCode ? <span className="tabular-nums">{log.statusCode}</span> : <span className="text-foreground/35">--</span>],
["任务 ID", log.taskId ? <code className="font-mono text-sm text-foreground/70">{log.taskId}</code> : <span className="text-foreground/35">--</span>],
["供应商任务 ID", log.providerRequestId ? <code className="font-mono text-sm text-foreground/70">{log.providerRequestId}</code> : <span className="text-foreground/35">--</span>],
["上游地址", log.upstreamUrl ? <code className="break-all text-sm text-foreground/70">{log.upstreamUrl}</code> : <span className="text-foreground/35">--</span>],
].map(([label, children], index) => ({ key: String(index), label, children }));
const canQueryProviderTask = log.capability === "video" && log.taskStatus === "failed" && Boolean(log.taskId && log.providerRequestId);
@@ -98,9 +135,9 @@ function LogDetail({ log, querying, onQueryProviderTask }: { log: ApiCallLog; qu
</Button>
</div>
) : null}
<Descriptions bordered size="small" column={{ xs: 1, sm: 1, md: 2 }} items={items} />
<Descriptions bordered size="middle" column={{ xs: 1, sm: 1, md: 1, lg: 2, xl: 2 }} labelStyle={{ width: "160px", fontWeight: 500 }} items={items} />
<section>
<div className="mb-2 text-sm font-semibold text-foreground/85">原始报文</div>
<div className="mb-3 text-base font-semibold text-foreground/90">原始报文</div>
<Tabs
items={[
{ key: "request", label: "请求报文", children: <PayloadPanel value={log.requestBody} empty="该请求未记录请求报文" /> },

View File

@@ -4,7 +4,7 @@ import { Callout } from "@/components/ui/product/callout";
import { RotateCcw } from "lucide-react";
import type { ReactNode } from "react";
import type { CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
import type { CapabilityImageSize, CapabilitySpec, OptionConstraint } from "@/services/api/logical-models";
import type { ChannelModel } from "@/services/api/wallet";
import { STANDARD_IMAGE_SIZE_VALUES } from "@/lib/model-capabilities";
@@ -106,6 +106,7 @@ export function capabilitySpecFromChannelModel(item?: ChannelModel): CapabilityS
transparentBackground: { values: image.transparentBackground.supported ? [false, true] : [false] },
count: { min: 1, max: image.maxOutputs, step: 1 },
}),
imageSize: image.size.parameter === "none" ? undefined : { parameter: image.size.parameter, allowCustom: image.size.allowCustom, presets: image.size.presets },
};
}
if (capability === "video") {
@@ -175,9 +176,22 @@ export function mergeCapabilitySpecs(capability: CapabilityKind, specs: Capabili
}
result.options![definition.name] = preferredSourceConstraint(constraints);
}
result.imageSize = mergeImageSize(matching);
return result;
}
function mergeImageSize(specs: CapabilitySpec[]): CapabilityImageSize | undefined {
const parts = specs.map((item) => item.imageSize).filter((item): item is CapabilityImageSize => Boolean(item));
if (!parts.length) return undefined;
const parameters = [...new Set(parts.map((item) => item.parameter).filter((value): value is NonNullable<CapabilityImageSize["parameter"]> => Boolean(value)))];
const presets = [...new Map(parts.flatMap((item) => item.presets || []).map((preset) => [`${preset.tier}:${preset.ratio}:${preset.size}`, preset])).values()];
return {
parameter: parameters.includes("aspect_ratio") ? "aspect_ratio" : parameters[0],
allowCustom: parts.some((item) => item.allowCustom),
presets,
};
}
/**
* 旧版本把“允许自定义”保存成只有 `*` 的能力范围。
* 管理端编辑时用当前供应线路的标准值补回展示范围,保存后即可完成数据修复。
@@ -191,7 +205,7 @@ export function normalizeCapabilitySpecForSources(value: CapabilitySpec | undefi
if (!sourceConstraint || !isWildcardConstraint(constraint) || !sourceConstraint.values) continue;
options[name] = { values: uniqueScalars([...(sourceConstraint.values || []), ...(constraint.values || [])]) };
}
return { ...value, options };
return { ...value, options, imageSize: value.imageSize?.presets?.length ? value.imageSize : source.imageSize || value.imageSize };
}
export function capabilitySourceError(capability: CapabilityKind, sourceSpecs: CapabilitySpec[], value?: CapabilitySpec) {

View File

@@ -2519,6 +2519,7 @@ function InfiniteCanvasPage() {
node={dialogNode}
viewport={viewport}
containerRef={containerRef}
allowOverflow={dialogNode.type !== CanvasNodeType.Config}
dragOffset={dragPreview?.nodeIds.has(dialogNode.id) ? { x: dragPreview.x, y: dragPreview.y } : null}
isDragging={isNodeDragging && Boolean(dragPreview?.nodeIds.has(dialogNode.id))}
>

View File

@@ -1,4 +1,4 @@
import { ArrowUpRight, UsersRound } from "lucide-react";
import { ArrowUpRight, BadgeCheck, UsersRound } from "lucide-react";
import { CometCard } from "@/components/ui/aceternity/comet-card";
@@ -13,20 +13,43 @@ export function WelcomeContributorsCard() {
<section className="welcome-contributors" aria-labelledby="welcome-contributors-title">
<CometCard containerClassName="welcome-contributors-perspective" className="welcome-contributors-card" rotateDepth={3.5} translateDepth={3}>
<div className="welcome-contributors-heading">
<span className="welcome-contributors-kicker"><UsersRound size={15} />Contributors</span>
<span className="welcome-contributors-kicker">
<UsersRound size={15} />
Contributors
</span>
<div>
<h3 id="welcome-contributors-title">一起把故事搬上银幕</h3>
<p>{welcomeContributors.length} 位创作者参与产品、开发、测试与社区建设</p>
</div>
<a href={githubContributors} target="_blank" rel="noreferrer" aria-label="在 GitHub README 查看贡献者清单">
查看清单<ArrowUpRight size={14} />
查看清单
<ArrowUpRight size={14} />
</a>
</div>
<ul className="welcome-contributors-list" aria-label="项目贡献者">
{welcomeContributors.map((contributor) => (
<li key={contributor.name} title={contributor.signature || contributor.name}>
<li
key={contributor.name}
className={contributor.isFounder ? "is-founder" : undefined}
title={contributor.signature || contributor.name}
aria-label={[contributor.name, contributor.wechat && `微信:${contributor.wechat}`, contributor.email].filter(Boolean).join(",")}
>
<img src={contributor.avatar} alt="" loading="lazy" />
<span>{contributor.name}</span>
{contributor.isFounder ? (
<div className="welcome-contributor-content">
<div className="welcome-contributor-name-row">
<span className="welcome-contributor-name">{contributor.name}</span>
<span className="welcome-contributor-badge">
<BadgeCheck size={13} />
项目发起者
</span>
</div>
<p className="welcome-contributor-meta">{[contributor.wechat && `微信:${contributor.wechat}`, contributor.email].filter(Boolean).join(" · ")}</p>
<p className="welcome-contributor-signature">{contributor.signature}</p>
</div>
) : (
<span>{contributor.name}</span>
)}
</li>
))}
</ul>

View File

@@ -10,6 +10,9 @@ export type WelcomeContributor = {
name: string;
avatar: string;
signature: string;
email?: string;
wechat?: string;
isFounder?: boolean;
};
function plainText(value: string) {
@@ -29,16 +32,33 @@ function parseContributors(markdown: string): WelcomeContributor[] {
.split("\n")
.filter((line) => /^\|\s*<img\s/i.test(line))
.flatMap((line) => {
const columns = line.slice(1, line.lastIndexOf("|")).split("|").map((column) => column.trim());
const columns = line
.slice(1, line.lastIndexOf("|"))
.split("|")
.map((column) => column.trim());
const image = columns[0]?.match(/src="([^"]+)"[^>]*alt="([^"]*)"/i);
const name = plainText(columns[1]?.split(/<br\s*\/?>/i)[0] ?? image?.[2] ?? "");
const nameColumn = columns[1] ?? "";
const name = plainText(nameColumn.split(/<br\s*\/?>/i)[0] ?? image?.[2] ?? "");
const avatarPath = image?.[1];
if (!avatarPath || !name) return [];
const avatar = contributorAvatarModules[`../../../../${avatarPath}`];
if (!avatar) return [];
return [{ name, avatar, signature: plainText(columns[3] ?? "") }];
const nameDetails = plainText(nameColumn);
const email = plainText(columns[2] ?? "");
const wechat = nameDetails.match(/微信\s*[::]\s*([^·]+)/)?.[1]?.trim();
return [
{
name,
avatar,
signature: plainText(columns[3] ?? ""),
email: email || undefined,
wechat,
isFounder: nameDetails.includes("项目发起者"),
},
];
});
}

View File

@@ -143,7 +143,8 @@ function WelcomeExperience({ look, brandName, onLookChange }: { look: WelcomeLoo
<div className="welcome-workbench-caption"><p>{active.detail}</p></div>
</section>
<section className="welcome-ending"><h2>你的故事,<br />现在开始。</h2><Button className="welcome-primary" type="primary" size="large" href="/create" icon={<ArrowRight size={20} />} iconPlacement="end">开始创作</Button><a className="welcome-source" href={github} target="_blank" rel="noreferrer"><Code2 size={16} />GitHub<ArrowUpRight size={14} /></a><WelcomeContributorsCard /></section>
<section className="welcome-ending"><h2>你的故事,<br />现在开始。</h2>
<WelcomeContributorsCard /></section>
</main>
<footer className="welcome-footer"><a href="/welcome">{brandName}</a><span>开源 AI 影视创作工作台</span><a href={`${github}/blob/main/LICENSE`} target="_blank" rel="noreferrer">Open Source · MIT License<ArrowUpRight size={12} /></a></footer>
{look.credit && <div className="welcome-media-credit"><a href={`/welcome/credits.html#${look.id}`} target="_blank" rel="noreferrer">{look.credit} · 署名与许可<ArrowUpRight size={12} /></a></div>}

File diff suppressed because it is too large Load Diff

View File

@@ -676,15 +676,8 @@ function logicalCapabilityOptions(config: AiConfig, mode: BackendGenerationMode)
? { audioVoice: config.audioVoice, audioFormat: config.audioFormat, audioSpeed: Number(config.audioSpeed) }
: {};
const filtered = Object.fromEntries(Object.entries(candidates).filter(([key]) => Boolean(spec?.options?.[key])));
// 图片质量和画幅同时参与按规格计费匹配。即使逻辑模型能力声明只把其中一项
// 暴露给供应线路,报价仍需要看到客户端最终选择,避免局部重绘等编辑请求落到
// “未配置所选规格”的错误分支。
if (mode === "image") {
for (const key of ["quality", "size"] as const) {
const value = candidates[key];
if (value !== undefined && value !== null && String(value).trim() !== "") filtered[key] = value;
}
}
// 只把前台模型声明过的参数送进能力匹配。未声明的 quality 不能因为画布选了 4K 档位
// 而被硬塞进去,否则会报“不支持参数 生成质量”;插件仍从 config.quality 读取档位。
return filtered;
}

View File

@@ -2,12 +2,25 @@ import { http } from "@/services/api/request";
export type InputConstraint = { min: number; max: number };
export type OptionConstraint = { values?: unknown[]; min?: number; max?: number; step?: number };
export type CapabilityImageSizePreset = {
size: string;
tier: "1k" | "2k" | "4k";
ratio: string;
width: number;
height: number;
};
export type CapabilityImageSize = {
parameter?: "size" | "aspect_ratio";
allowCustom?: boolean;
presets?: CapabilityImageSizePreset[];
};
export type CapabilitySpec = {
version: 1;
capability: "text" | "image" | "video" | "audio";
operations?: string[];
inputs?: Record<string, InputConstraint>;
options?: Record<string, OptionConstraint>;
imageSize?: CapabilityImageSize;
};
export type ModelRequestIntent = {

View File

@@ -8193,6 +8193,12 @@
padding: var(--space-3);
}
/* 摄像机控制覆盖在提示词编辑区上方,不参与编辑区的尺寸计算。 */
.canvas-node-composer.has-camera-panel {
overflow: visible;
z-index: 20;
}
/* 提示词优化保持在输入框附近,使用紧凑的 Popover 作为第二层输入工具。 */
.canvas-prompt-optimizer-popover-surface {
width: min(var(--canvas-prompt-optimizer-width, 420px), calc(100vw - 24px));
@@ -9337,6 +9343,55 @@
background: transparent;
}
.canvas-node-composer-camera-panel {
position: absolute;
right: var(--space-3);
bottom: calc(var(--space-8) + var(--space-3));
z-index: 20;
display: block;
width: min(780px, calc(100% - (var(--space-3) * 2)));
max-height: min(620px, calc(100dvh - 32px));
min-width: 0;
overflow: auto;
pointer-events: auto;
border-radius: var(--r-xl);
}
.canvas-node-camera-switch:hover,
.canvas-node-camera-switch:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 12%, transparent);
}
.canvas-node-camera-apply:hover,
.canvas-node-camera-apply:focus-visible {
background: color-mix(in srgb, currentColor 10%, transparent) !important;
}
.canvas-reference-autolink-switch:hover,
.canvas-reference-autolink-switch:focus-visible {
box-shadow: 0 0 0 3px color-mix(in srgb, currentColor 10%, transparent);
}
.canvas-node-composer-camera-tools-trigger:focus,
.canvas-node-composer-camera-tools-trigger:focus-visible {
outline: none;
box-shadow: none;
}
.canvas-reference-tools-mention-button:hover,
.canvas-reference-tools-mention-button:focus-visible {
background: color-mix(in srgb, currentColor 9%, transparent) !important;
}
@media (max-width: 640px) {
.canvas-node-composer-camera-panel {
right: var(--space-2);
left: var(--space-2);
bottom: calc(var(--space-8) + var(--space-2));
width: auto;
}
}
.canvas-node-composer-references {
display: block;
min-width: 0;
@@ -13078,6 +13133,22 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
.creation-custom-value em { color: var(--creation-muted); font-size: 11px; font-style: normal; }
/* 首页图像参数保持紧凑,避免比例预设把弹层撑成整屏高度。 */
.creation-parameter-menu .image-size-picker {
gap: 7px;
}
.creation-parameter-menu .image-size-ratios {
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 4px;
}
.creation-parameter-menu .image-size-ratios button {
min-height: 48px;
gap: 4px;
padding: 5px;
}
.creation-duration-custom-field {
display: inline-flex;
min-width: 64px;