feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理
This commit is contained in:
@@ -7,6 +7,7 @@ import { configuredModelMatchesCapability, defaultConfig, modelOptionName, resol
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed } from "@/lib/model-capabilities";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -45,7 +46,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const config = buildNodeConfig(globalConfig, node, mode);
|
||||
const operationOptions = node.metadata?.videoEditOperation === "concat" ? [...videoOperationOptions, { label: "合并成片", value: "concat" as const }] : videoOperationOptions;
|
||||
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
|
||||
const operationOptions = videoProfile ? videoOperationOptions.filter((item) => videoProfile.operations.includes(item.value) || item.value === "concat") : videoOperationOptions;
|
||||
const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
const priceChannel = resolveModelChannel(config, config.model);
|
||||
const credits = requestCreditCost({ channelMode: priceChannel.scope === "system" ? "remote" : "local", modelCosts: priceChannel.modelCosts, model: modelOptionName(config.model), count: mode === "image" ? count : 1, seconds: mode === "video" ? config.videoSeconds : 1 });
|
||||
@@ -53,7 +55,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount);
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
const canGenerate = hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput);
|
||||
const capabilityError = videoProfile ? videoCapabilityError(videoProfile, config.videoSeconds, node.metadata?.composerContent ?? node.metadata?.prompt ?? "", inputSummary, node.metadata?.videoEditOperation) : "";
|
||||
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
|
||||
|
||||
return (
|
||||
<div className="flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
@@ -153,6 +156,8 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</div>
|
||||
)}
|
||||
|
||||
{capabilityError ? <div className="mb-2 rounded-md px-2 py-1.5 text-[var(--fs-tiny)]" style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
className="mt-auto !h-9 !w-full !cursor-pointer !rounded-lg"
|
||||
@@ -213,16 +218,18 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = node.metadata?.model;
|
||||
const model = storedModel && configuredModelMatchesCapability(globalConfig, storedModel, mode) ? storedModel : defaultModel && configuredModelMatchesCapability(globalConfig, defaultModel, mode) ? defaultModel : fallbackModel;
|
||||
const videoProfile = mode === "video" ? modelCapabilityConfigFor(globalConfig, model).video! : undefined;
|
||||
const normalizedVideo = videoProfile ? normalizeVideoValue(videoProfile, { seconds: node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds, ratio: node.metadata?.size || globalConfig.size || defaultConfig.size, resolution: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality }) : undefined;
|
||||
return {
|
||||
...globalConfig,
|
||||
model,
|
||||
quality: node.metadata?.quality || globalConfig.quality || defaultConfig.quality,
|
||||
size: node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
size: normalizedVideo?.ratio || node.metadata?.size || globalConfig.size || defaultConfig.size,
|
||||
transparentBackground: (node.metadata?.transparentBackground || globalConfig.transparentBackground) === "true" ? "true" : "false",
|
||||
videoSeconds: normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
vquality: normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio,
|
||||
videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark,
|
||||
videoSeconds: normalizedVideo?.seconds || normalizeVideoDuration(node.metadata?.seconds || globalConfig.videoSeconds || defaultConfig.videoSeconds),
|
||||
vquality: normalizedVideo?.resolution.replace(/p$/i, "") || normalizeVideoResolution(node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality),
|
||||
videoGenerateAudio: videoProfile?.generateAudio.supported ? node.metadata?.generateAudio || globalConfig.videoGenerateAudio || String(videoProfile.generateAudio.default) : "false",
|
||||
videoWatermark: videoProfile?.watermark.supported ? node.metadata?.watermark || globalConfig.videoWatermark || String(videoProfile.watermark.default) : "false",
|
||||
audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice || defaultConfig.audioVoice,
|
||||
audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat || defaultConfig.audioFormat,
|
||||
audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed || defaultConfig.audioSpeed,
|
||||
@@ -238,6 +245,15 @@ function videoConfigPatch(key: keyof AiConfig, value: string) {
|
||||
return { [key]: value };
|
||||
}
|
||||
|
||||
function videoCapabilityError(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, seconds: string, prompt: string, input: CanvasConfigNodePanelProps["inputSummary"], operation?: string) {
|
||||
if (!videoDurationAllowed(profile, Number(seconds))) return "当前模型不支持该视频时长";
|
||||
if (Array.from(prompt).length > profile.references.promptMaxChars) return `提示词超过模型限制(最多 ${profile.references.promptMaxChars} 字)`;
|
||||
if (input.imageCount > profile.references.maxImages || input.videoCount > profile.references.maxVideos || input.audioCount > profile.references.maxAudios) return "参考素材数量超过当前模型限制";
|
||||
const resolvedOperation = operation || (input.audioCount > 0 && input.imageCount === 0 && input.videoCount === 0 ? "audio_to_video" : input.videoCount > 0 ? "extend" : input.imageCount > 0 ? "image_to_video" : "text_to_video");
|
||||
if (!profile.operations.includes(resolvedOperation)) return "当前模型不支持该生成模式";
|
||||
return "";
|
||||
}
|
||||
|
||||
function audioConfigPatch(key: CanvasAudioSettingKey, value: string) {
|
||||
if (key === "audioVoice") return { audioVoice: value };
|
||||
if (key === "audioFormat") return { audioFormat: value };
|
||||
|
||||
@@ -77,7 +77,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
const modalShadow = darkSurface ? `0 30px 90px ${theme.spatial.shadow}` : "0 24px 72px rgba(15,23,42,.16)";
|
||||
const referenceShelfHeight = activeReferenceCount ? 42 : 0;
|
||||
const composerMinHeight = activeReferenceCount ? 82 : 58;
|
||||
const composerHeight = Math.min(144, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
|
||||
const composerHeight = Math.min(224, Math.max(composerMinHeight, Math.ceil(promptContentHeight + referenceShelfHeight)));
|
||||
const isSubmitDisabled = !isRunning && !prompt.trim();
|
||||
const canExpandPrompt = mode === "image" || mode === "video";
|
||||
const isPortraitTexture = mode === "image" && Boolean(node.metadata?.portraitTexture);
|
||||
@@ -248,7 +248,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
{renderComposerHeader(false)}
|
||||
|
||||
<div
|
||||
className="relative mt-2 flex max-h-36 flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
|
||||
className="relative mt-2 flex max-h-[var(--prompt-panel-input-max-height)] flex-col overflow-hidden rounded-xl outline-none ring-0 transition-[height] duration-150 focus-within:outline-none focus-within:ring-0 motion-reduce:transition-none"
|
||||
style={{ height: composerHeight, background: composerSurface, boxShadow: composerShadow }}
|
||||
>
|
||||
<ConnectedReferenceShelf references={mentionReferences} theme={theme} onInsert={insertPromptReference} />
|
||||
|
||||
@@ -462,18 +462,25 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
{data.metadata?.versionLabel ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute left-3 top-3 z-[var(--node-z-overlay)] inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-tiny)] font-semibold backdrop-blur transition hover:brightness-110"
|
||||
style={{ background: theme.toolbar.panel, borderColor: data.metadata.versionPrimary ? theme.node.activeStroke : theme.toolbar.border, color: data.metadata.versionPrimary ? theme.node.activeStroke : theme.node.text }}
|
||||
title="查看版本对比"
|
||||
className="absolute left-3 top-3 z-[var(--node-z-overlay)] grid size-7 place-items-center rounded-[var(--r-full)] border p-0.5 text-[var(--node-badge-fs)] font-semibold leading-none backdrop-blur-md transition-[transform,background,border-color,box-shadow] hover:-translate-y-px focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 motion-reduce:hover:translate-y-0"
|
||||
style={{
|
||||
background: data.metadata.versionPrimary ? theme.accent.primarySoft : theme.toolbar.panel,
|
||||
borderColor: data.metadata.versionPrimary ? theme.accent.primary : theme.toolbar.border,
|
||||
color: data.metadata.versionPrimary ? theme.accent.primary : theme.node.text,
|
||||
boxShadow: data.metadata.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : "var(--shadow-sm)",
|
||||
outlineColor: theme.accent.primary,
|
||||
}}
|
||||
title={data.metadata.versionLabel + (data.metadata.versionPrimary ? " · 主版本" : "") + ",点击查看版本对比"}
|
||||
aria-label={data.metadata.versionLabel + (data.metadata.versionPrimary ? ",主版本" : "") + ",查看版本对比"}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={(event) => { event.stopPropagation(); onOpenVersions?.(data); }}
|
||||
>
|
||||
<Star className={`size-3 ${data.metadata.versionPrimary ? "fill-current" : ""}`} />{data.metadata.versionLabel}
|
||||
{data.metadata.versionLabel}
|
||||
</button>
|
||||
) : null}
|
||||
{showStatusTrack ? (
|
||||
<div className={`absolute right-3 top-3 z-[var(--node-z-overlay)] flex min-w-0 items-center justify-end gap-1 ${data.metadata?.versionLabel ? "max-w-[calc(100%-104px)]" : "max-w-[calc(100%-24px)]"}`}>
|
||||
{resourceLabel ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||||
{resourceLabel && data.type !== CanvasNodeType.Image ? <ResourceLabelBadge reference={resourceLabel} theme={theme} /> : null}
|
||||
{hasMediaContent && !readOnly ? <ResourceStorageBadge storageKey={data.metadata?.storageKey} active={isActive} theme={theme} /> : null}
|
||||
{isBatchRoot ? <BatchToggleBadge count={batchCount} expanded={batchExpanded} theme={theme} onToggle={() => onToggleBatch?.(data.id)} /> : null}
|
||||
{isBatchChild && !readOnly ? <BatchPrimaryBadge visible={batchPrimary || hovered || isSelected} selected={batchPrimary} theme={theme} onSelect={() => onSetBatchPrimary?.(data)} /> : null}
|
||||
|
||||
@@ -12,10 +12,10 @@ export function CanvasVersionCompareModal({ open, versions, onClose, onSetPrimar
|
||||
<Modal title="版本对比" open={open} footer={null} width={modalWidth} centered onCancel={onClose} styles={{ body: { overflow: "hidden" } }}>
|
||||
<div className="thin-scrollbar grid max-h-[70vh] grid-flow-col auto-cols-[328px] gap-3 overflow-x-auto pb-2">
|
||||
{versions.map((node) => (
|
||||
<article key={node.id} className="overflow-hidden rounded-lg border" style={{ borderColor: node.metadata?.versionPrimary ? theme.node.activeStroke : theme.node.stroke, background: theme.node.panel }}>
|
||||
<article key={node.id} className="overflow-hidden rounded-[var(--r-lg)] border" style={{ borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, background: theme.node.panel, boxShadow: node.metadata?.versionPrimary ? "0 0 0 2px " + theme.accent.primarySoft : undefined }}>
|
||||
<div className="flex h-11 items-center justify-between gap-3 border-b px-3" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-6 shrink-0 place-items-center rounded-md text-xs font-bold" style={{ background: theme.toolbar.itemHover }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.activeStroke }}><Check className="size-3" />主版本</span> : null}
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2"><span className="grid size-7 shrink-0 place-items-center rounded-[var(--r-full)] border text-[var(--node-badge-fs)] font-semibold leading-none" style={{ background: node.metadata?.versionPrimary ? theme.accent.primarySoft : theme.toolbar.itemHover, borderColor: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.stroke, color: node.metadata?.versionPrimary ? theme.accent.primary : theme.node.text }}>{node.metadata?.versionLabel || "-"}</span><span className="min-w-0 truncate text-xs font-semibold" title={node.title}>{node.title}</span></div>
|
||||
{node.metadata?.versionPrimary ? <span className="inline-flex shrink-0 items-center gap-1 whitespace-nowrap text-[var(--fs-tiny)] font-medium" style={{ color: theme.accent.primary }}><Check className="size-3" />主版本</span> : null}
|
||||
</div>
|
||||
<button type="button" className="block h-52 w-full overflow-hidden" style={{ background: theme.node.fill }} onClick={() => onFocus(node.id)}>
|
||||
{node.type === CanvasNodeType.Image && node.metadata?.content ? <img src={node.metadata.content} alt={node.title || "版本图片"} className="size-full object-contain" /> : node.type === CanvasNodeType.Video && node.metadata?.content ? <video src={node.metadata.content} className="size-full object-contain" muted preload="metadata" aria-label={node.title || "版本视频"} /> : <span className="grid size-full place-items-center px-4 text-center text-xs" style={{ color: theme.node.muted }}>点击定位到画布节点</span>}
|
||||
|
||||
118
web/src/components/model-capability-editor.tsx
Normal file
118
web/src/components/model-capability-editor.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Input, InputNumber, Segmented, Select, Switch } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
|
||||
const ratioOptions = ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"];
|
||||
const resolutionOptions = ["480p", "720p", "1080p", "2160p"];
|
||||
const operationOptions = [
|
||||
{ label: "文生视频", value: "text_to_video" },
|
||||
{ label: "图生视频", value: "image_to_video" },
|
||||
{ label: "视频续写", value: "extend" },
|
||||
{ label: "局部修改", value: "inpaint" },
|
||||
{ label: "元素替换", value: "replace_element" },
|
||||
{ label: "运镜调整", value: "camera_motion" },
|
||||
{ label: "风格迁移", value: "style_transfer" },
|
||||
{ label: "音频生视频", value: "audio_to_video" },
|
||||
];
|
||||
|
||||
type Props = {
|
||||
value?: ModelCapabilityConfig;
|
||||
onChange?: (value: ModelCapabilityConfig) => void;
|
||||
protocol?: ModelProtocol;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ModelCapabilityEditor({ value, onChange, protocol, disabled = false }: Props) {
|
||||
const profile = value?.video || defaultModelCapabilityConfig(protocol).video!;
|
||||
const update = (patch: Partial<VideoCapabilityConfig>) => onChange?.({ version: 1, video: { ...profile, ...patch } });
|
||||
const updateReferences = (patch: Partial<VideoCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
|
||||
const updateDuration = (patch: Partial<VideoCapabilityConfig["duration"]>) => update({ duration: { ...profile.duration, ...patch } });
|
||||
const durationValues = (profile.duration.values || []).join(",");
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><div className="text-sm font-medium">视频能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">这些参数会同步到创造页、画布和生成校验</div></div>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/40">协议模板可继续调整</span>
|
||||
</div>
|
||||
|
||||
<CapabilityGroup title="引用限制">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<NumberField label="提示词字符数" value={profile.references.promptMaxChars} min={1} disabled={disabled} onChange={(value) => updateReferences({ promptMaxChars: value || 1 })} />
|
||||
<NumberField label="最大图片引用" value={profile.references.maxImages} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImages: value || 0 })} />
|
||||
<NumberField label="图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxImageBytes: mbToBytes(value) })} />
|
||||
<NumberField label="最大视频引用" value={profile.references.maxVideos} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideos: value || 0 })} />
|
||||
<NumberField label="视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoBytes: mbToBytes(value) })} />
|
||||
<NumberField label="视频最长秒数" value={profile.references.maxVideoDurationSeconds} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxVideoDurationSeconds: value || 0 })} />
|
||||
<NumberField label="最大音频引用" value={profile.references.maxAudios} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudios: value || 0 })} />
|
||||
<NumberField label="音频上限 MB" value={bytesToMB(profile.references.maxAudioBytes)} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudioBytes: mbToBytes(value) })} />
|
||||
<NumberField label="音频最长秒数" value={profile.references.maxAudioDurationSeconds} min={0} disabled={disabled} onChange={(value) => updateReferences({ maxAudioDurationSeconds: value || 0 })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
|
||||
<CapabilityGroup title="视频时长">
|
||||
<Segmented block disabled={disabled} value={profile.duration.selection} options={[{ label: "范围", value: "range" }, { label: "固定值", value: "enum" }]} onChange={(value) => updateDuration(value === "enum" ? { selection: "enum", values: profile.duration.values?.length ? profile.duration.values : [profile.duration.default] } : { selection: "range", min: profile.duration.min || 1, max: profile.duration.max || 15, step: profile.duration.step || 1 })} />
|
||||
{profile.duration.selection === "enum" ? (
|
||||
<Field label="固定时长(秒)"><Input disabled={disabled} value={durationValues} placeholder="例如:5,10" onChange={(event) => updateDuration({ values: parseIntegerList(event.target.value), default: parseIntegerList(event.target.value)[0] || profile.duration.default })} /></Field>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<NumberField label="最短" value={profile.duration.min} min={1} disabled={disabled} onChange={(value) => updateDuration({ min: value || 1 })} />
|
||||
<NumberField label="最长" value={profile.duration.max} min={1} disabled={disabled} onChange={(value) => updateDuration({ max: value || 1 })} />
|
||||
<NumberField label="步长" value={profile.duration.step} min={1} disabled={disabled} onChange={(value) => updateDuration({ step: value || 1 })} />
|
||||
<NumberField label="默认" value={profile.duration.default} min={1} disabled={disabled} onChange={(value) => updateDuration({ default: value || 1 })} />
|
||||
</div>
|
||||
)}
|
||||
</CapabilityGroup>
|
||||
|
||||
<CapabilityGroup title="输出参数">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="画面比例"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.ratios} options={ratioOptions.map((item) => ({ label: item, value: item }))} onChange={(ratios) => update({ ratios, defaultRatio: ratios.includes(profile.defaultRatio) ? profile.defaultRatio : ratios[0] || "16:9" })} /></Field>
|
||||
<Field label="默认比例"><Select className="w-full" disabled={disabled} value={profile.defaultRatio} options={profile.ratios.map((item) => ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /></Field>
|
||||
<Field label="输出分辨率"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.resolutions} options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "720p" })} /></Field>
|
||||
<Field label="默认分辨率"><Select className="w-full" disabled={disabled} value={profile.defaultResolution} options={profile.resolutions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(defaultResolution) => update({ defaultResolution })} /></Field>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<BooleanField label="同步音频" value={profile.generateAudio} disabled={disabled} onChange={(generateAudio) => update({ generateAudio })} />
|
||||
<BooleanField label="水印" value={profile.watermark} disabled={disabled} onChange={(watermark) => update({ watermark })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
|
||||
<CapabilityGroup title="生成模式">
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="支持模式"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.operations} options={operationOptions} onChange={(operations) => update({ operations, defaultOperation: operations.includes(profile.defaultOperation) ? profile.defaultOperation : operations[0] || "text_to_video" })} /></Field>
|
||||
<Field label="默认模式"><Select className="w-full" disabled={disabled} value={profile.defaultOperation} options={operationOptions.filter((item) => profile.operations.includes(item.value))} onChange={(defaultOperation) => update({ defaultOperation })} /></Field>
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CapabilityGroup({ title, children }: { title: string; children: ReactNode }) {
|
||||
return <section className="space-y-2"><div className="text-xs font-semibold text-foreground/65">{title}</div>{children}</section>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return <label className="block min-w-0"><span className="mb-1 block text-[var(--fs-tiny)] text-foreground/48">{label}</span>{children}</label>;
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, disabled, onChange }: { label: string; value?: number; min: number; disabled: boolean; onChange: (value: number | null) => void }) {
|
||||
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} precision={0} value={value} onChange={onChange} /></Field>;
|
||||
}
|
||||
|
||||
function BooleanField({ label, value, disabled, onChange }: { label: string; value: { supported: boolean; default: boolean }; disabled: boolean; onChange: (value: { supported: boolean; default: boolean }) => void }) {
|
||||
return <div className="flex items-center justify-between rounded-md border border-border/60 px-2.5 py-2"><div><div className="text-xs font-medium">{label}</div><div className="text-[var(--fs-tiny)] text-foreground/45">支持该参数</div></div><div className="flex items-center gap-2"><Switch size="small" disabled={disabled} checked={value.supported} onChange={(supported) => onChange({ ...value, supported })} /><Switch size="small" disabled={disabled || !value.supported} checked={value.default} onChange={(defaultValue) => onChange({ ...value, default: defaultValue })} /></div></div>;
|
||||
}
|
||||
|
||||
function bytesToMB(value: number) {
|
||||
return value ? Math.round((value / (1024 * 1024)) * 10) / 10 : 0;
|
||||
}
|
||||
|
||||
function mbToBytes(value: number | null) {
|
||||
return Math.max(0, Math.round(Number(value || 0) * 1024 * 1024));
|
||||
}
|
||||
|
||||
function parseIntegerList(value: string) {
|
||||
return Array.from(new Set(value.split(",").map((item) => Number(item.trim())).filter((item) => Number.isInteger(item) && item > 0))).sort((left, right) => left - right);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Check, ChevronDown, Coins, Cpu } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
@@ -206,6 +207,8 @@ function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
|
||||
function ModelLabel({ config, model, capability, theme, creationVariant, showPrice }: { config: AiConfig; model: string; capability?: ModelCapability; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; creationVariant: boolean; showPrice: boolean }) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
|
||||
const capabilitySummary = videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description;
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
|
||||
@@ -213,7 +216,7 @@ function ModelLabel({ config, model, capability, theme, creationVariant, showPri
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={meta.description}>{meta.description}</span>
|
||||
<span className="mt-1 block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }} title={capabilitySummary}>{capabilitySummary}</span>
|
||||
</span>
|
||||
{showPrice ? <ModelPrice price={modelMenuPrice(config, model)} /> : null}
|
||||
{!creationVariant && meta.time ? <span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>{meta.time}</span> : null}
|
||||
@@ -221,6 +224,12 @@ function ModelLabel({ config, model, capability, theme, creationVariant, showPri
|
||||
);
|
||||
}
|
||||
|
||||
function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>) {
|
||||
const values = videoDurationOptions(profile);
|
||||
if (profile.duration.selection === "enum") return values.map((item) => `${item}s`).join("/");
|
||||
return `${profile.duration.min || values[0]}-${profile.duration.max || values[values.length - 1]}s`;
|
||||
}
|
||||
|
||||
function modelMenuPrice(config: AiConfig, model: string): { value: number; unit: "次" | "秒" } | null | undefined {
|
||||
if (!model) return undefined;
|
||||
const channel = resolveModelChannel(config, model);
|
||||
|
||||
168
web/src/components/model-protocol-picker.tsx
Normal file
168
web/src/components/model-protocol-picker.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import { AudioLines, Check, Film, Flame, Image, MessageSquareText, Network, Settings2, Sparkles } from "lucide-react";
|
||||
import { Button, Modal } from "antd";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
import { MODEL_PROTOCOLS, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition, type ProtocolCapability } from "@/lib/model-protocols";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ModelCapabilityChoice = ProtocolCapability;
|
||||
|
||||
const capabilityChoices: Array<{
|
||||
value: ModelCapabilityChoice;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
brands: string[];
|
||||
}> = [
|
||||
{ value: "text", label: "文本", description: "对话与推理", icon: <MessageSquareText className="size-4" />, brands: ["openai", "deepseek", "glm"] },
|
||||
{ value: "image", label: "图片", description: "生成与编辑", icon: <Image className="size-4" />, brands: ["openai", "gemini"] },
|
||||
{ value: "video", label: "视频", description: "生成与续写", icon: <Film className="size-4" />, brands: ["grok", "gemini"] },
|
||||
{ value: "audio", label: "音频", description: "语音与音效", icon: <AudioLines className="size-4" />, brands: ["openai"] },
|
||||
];
|
||||
|
||||
type PickerDensity = "comfortable" | "compact";
|
||||
|
||||
export function CapabilityCardPicker({ value, onChange, density = "comfortable" }: { value?: ModelCapabilityChoice; onChange?: (value: ModelCapabilityChoice) => void; density?: PickerDensity }) {
|
||||
return (
|
||||
<div className={cn("grid grid-cols-2 gap-2 sm:grid-cols-4", density === "compact" && "gap-1.5")} role="radiogroup" aria-label="模型能力">
|
||||
{capabilityChoices.map((item) => {
|
||||
const selected = value === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-col rounded-md border text-left outline-none transition-colors focus-visible:ring-1 focus-visible:ring-foreground/40",
|
||||
density === "compact" ? "min-h-16 p-2" : "min-h-24 p-2.5",
|
||||
selected ? "border-foreground/70 bg-foreground/[.04]" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => onChange?.(item.value)}
|
||||
>
|
||||
<span className={cn("grid place-items-center rounded-md", density === "compact" ? "size-6" : "size-8", selected ? "bg-foreground text-background" : "bg-muted text-foreground/65")}>{item.icon}</span>
|
||||
{selected ? <span className={cn("absolute grid place-items-center rounded-full bg-foreground text-background", density === "compact" ? "right-1.5 top-1.5 size-4" : "right-2.5 top-2.5 size-5")}><Check className={density === "compact" ? "size-2.5" : "size-3"} /></span> : null}
|
||||
<span className={cn("block font-semibold", density === "compact" ? "mt-1 text-xs" : "mt-2 text-sm")}>{item.label}</span>
|
||||
<span className={cn("block text-foreground/48", density === "compact" ? "text-[var(--fs-micro)]" : "text-xs")}>{item.description}</span>
|
||||
{density === "comfortable" ? <BrandIconRow models={item.brands} className="mt-auto pt-2" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtocolCardPicker({ capability, value, onChange, density = "comfortable" }: { capability?: ModelCapabilityChoice; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void; density?: PickerDensity }) {
|
||||
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
|
||||
return (
|
||||
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2", density === "compact" && "gap-1.5")} role="radiogroup" aria-label="模型请求协议">
|
||||
{protocols.map((protocol) => {
|
||||
const selected = value === protocol.value;
|
||||
return (
|
||||
<button
|
||||
key={protocol.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-col rounded-md border text-left outline-none transition-colors focus-visible:ring-1 focus-visible:ring-foreground/40",
|
||||
density === "compact" ? "min-h-16 p-2" : "min-h-24 p-2.5",
|
||||
selected ? "border-foreground/70 bg-foreground/[.04]" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => onChange?.(protocol.value)}
|
||||
>
|
||||
<div className={cn("flex min-w-0 items-start", density === "compact" ? "gap-1.5 pr-4" : "gap-2.5 pr-6")}>
|
||||
<ProtocolBrandMark protocol={protocol} compact={density === "compact"} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className={cn("truncate font-semibold", density === "compact" ? "text-xs" : "text-sm")}>{protocol.label}</div>
|
||||
<div className={cn("mt-0.5 truncate font-mono text-foreground/48", density === "compact" ? "text-[var(--fs-micro)]" : "text-[var(--fs-tiny)]")}>{protocol.create}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selected ? <span className={cn("absolute grid place-items-center rounded-full bg-foreground text-background", density === "compact" ? "right-1.5 top-1.5 size-4" : "right-2.5 top-2.5 size-5")}><Check className={density === "compact" ? "size-2.5" : "size-3"} /></span> : null}
|
||||
{density === "comfortable" ? <>
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-foreground/58">{protocol.media}</div>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-2 text-[var(--fs-tiny)] text-foreground/42">
|
||||
<span className="truncate">{protocol.contentType}</span>
|
||||
{protocol.poll ? <span className="shrink-0">异步轮询</span> : <span className="shrink-0">同步响应</span>}
|
||||
</div>
|
||||
</> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelCapabilityProtocolModal({ value, onChange }: { value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }; onChange: (value: { capability: ModelCapabilityChoice; protocol: ModelProtocol }) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDraft(value);
|
||||
}, [open, value]);
|
||||
|
||||
const updateCapability = (capability: ModelCapabilityChoice) => {
|
||||
const protocol = draft.protocol && MODEL_PROTOCOLS.some((item) => item.value === draft.protocol && item.capability === capability)
|
||||
? draft.protocol
|
||||
: MODEL_PROTOCOLS.find((item) => item.capability === capability)?.value || "chat-completion";
|
||||
setDraft({ capability, protocol });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button size="small" className="max-w-full justify-start" icon={<Settings2 className="size-3.5" />} onClick={() => { setDraft(value); setOpen(true); }}>
|
||||
<span className="max-w-[min(56vw,360px)] truncate">{capabilityLabel(value.capability)} · {modelProtocolLabel(value.protocol)}</span>
|
||||
</Button>
|
||||
<Modal
|
||||
title="配置模型能力与请求协议"
|
||||
open={open}
|
||||
width="min(720px, calc(100vw - 24px))"
|
||||
centered
|
||||
destroyOnHidden
|
||||
onCancel={() => setOpen(false)}
|
||||
okText="应用配置"
|
||||
cancelText="取消"
|
||||
onOk={() => { onChange(draft); setOpen(false); }}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-semibold text-foreground/65">模型能力</div>
|
||||
<CapabilityCardPicker value={draft.capability} onChange={updateCapability} />
|
||||
</section>
|
||||
<section>
|
||||
<div className="mb-2 text-xs font-semibold text-foreground/65">请求协议</div>
|
||||
<ProtocolCardPicker capability={draft.capability} value={draft.protocol} onChange={(protocol) => setDraft((current) => ({ ...current, protocol }))} />
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolBrandMark({ protocol, compact = false }: { protocol: ModelProtocolDefinition; compact?: boolean }) {
|
||||
const iconSize = compact ? "size-6" : "size-8";
|
||||
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact={compact} />;
|
||||
if (protocol.value.startsWith("volcengine-jimeng-")) return <span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}><Sparkles className={compact ? "size-3" : "size-4"} /></span>;
|
||||
if (protocol.value.startsWith("volcengine-")) return <span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}><Flame className={compact ? "size-3" : "size-4"} /></span>;
|
||||
if (protocol.value.startsWith("newapi-channel-")) return <span className={cn("grid shrink-0 place-items-center rounded-md bg-muted text-foreground/65", iconSize)}><Network className={compact ? "size-3" : "size-4"} /></span>;
|
||||
const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai";
|
||||
return <BrandIconRow models={[brand]} compact={compact} />;
|
||||
}
|
||||
|
||||
function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center -space-x-1", compact && "shrink-0", className)} aria-hidden="true">
|
||||
{models.map((model) => <span key={model} className={cn("grid shrink-0 place-items-center rounded-md border border-border/70 bg-background", compact ? "size-6" : "size-6")} title={modelBrandLabel(model)}><ModelIcon model={model} /></span>)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function modelBrandLabel(model: string) {
|
||||
const labels: Record<string, string> = { openai: "OpenAI", deepseek: "DeepSeek", glm: "智谱 GLM", gemini: "Google Gemini", grok: "xAI Grok" };
|
||||
return labels[model] || model;
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ModelCapabilityChoice) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频" }[value];
|
||||
}
|
||||
@@ -2,13 +2,12 @@ import { type ReactNode } from "react";
|
||||
import { Switch } from "antd";
|
||||
|
||||
import { ImageSettingsTheme } from "@/components/image-settings-panel";
|
||||
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceDurationOptions, seedanceRatioOptions, seedanceResolutionOptions } from "@/lib/seedance-video";
|
||||
import { boolConfig, isSeedanceFastModel, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceRatioOptions } from "@/lib/seedance-video";
|
||||
import { type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN, VIDEO_DURATION_OPTIONS, VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution, VIDEO_DURATION_MIN } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
const resolutionOptions = VIDEO_RESOLUTION_OPTIONS.map((value) => ({ value: String(value), label: `${value}P` }));
|
||||
|
||||
const sizeOptions = [
|
||||
{ value: "1280x720", label: "横屏", width: 1280, height: 720 },
|
||||
{ value: "720x1280", label: "竖屏", width: 720, height: 1280 },
|
||||
@@ -18,9 +17,6 @@ const sizeOptions = [
|
||||
{ value: "auto", label: "auto", width: 0, height: 0 },
|
||||
];
|
||||
|
||||
const defaultSecondOptions = VIDEO_DURATION_OPTIONS;
|
||||
const jiMengRatioOptions = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"];
|
||||
|
||||
type VideoSettingsPanelProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: "vquality" | "size" | "videoSeconds" | "videoGenerateAudio" | "videoWatermark", value: string) => void;
|
||||
@@ -30,18 +26,23 @@ type VideoSettingsPanelProps = {
|
||||
};
|
||||
|
||||
export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle = true, className = "w-[292px] space-y-3" }: VideoSettingsPanelProps) {
|
||||
const profile = modelCapabilityConfigFor(config, config.model).video!;
|
||||
if (resolveModelRequestConfig(config, config.model).interfaceType === "volcengine-jimeng-video") {
|
||||
return <JiMengVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
return <JiMengVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
}
|
||||
if (isSeedanceVideoConfig(config)) {
|
||||
return <SeedanceVideoSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
return <SeedanceVideoSettingsPanel config={config} profile={profile} onConfigChange={onConfigChange} theme={theme} showTitle={showTitle} className={className} />;
|
||||
}
|
||||
|
||||
const seconds = normalizeVideoDuration(config.videoSeconds);
|
||||
const secondOptions = defaultSecondOptions;
|
||||
const secondOptions = videoDurationOptions(profile);
|
||||
const durationColumns = secondOptions.length + (profile.duration.selection === "range" ? 1 : 0);
|
||||
const size = normalizeVideoSizeValue(config.size);
|
||||
const dimensions = readSizeDimensions(size);
|
||||
const resolution = normalizeVideoResolutionValue(config.vquality);
|
||||
const configuredResolutions = profile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value.toUpperCase() }));
|
||||
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
|
||||
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
|
||||
const updateDimension = (key: "width" | "height", value: number | null) => {
|
||||
const next = Math.max(1, Math.floor(value || dimensions[key] || 720));
|
||||
onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`);
|
||||
@@ -53,7 +54,7 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{resolutionOptions.map((item) => (
|
||||
{configuredResolutions.map((item) => (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
{item.label}
|
||||
</OptionPill>
|
||||
@@ -67,37 +68,38 @@ export function VideoSettingsPanel({ config, onConfigChange, theme, showTitle =
|
||||
<DimensionInput prefix="H" value={dimensions.height} disabled={size === "auto"} theme={theme} onChange={(value) => updateDimension("height", value)} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{sizeOptions.map((item) => (
|
||||
{profile.ratios.map((value) => (
|
||||
<button
|
||||
key={item.value}
|
||||
key={value}
|
||||
type="button"
|
||||
className="flex h-8 cursor-pointer items-center justify-center gap-1.5 rounded-md px-1 text-[var(--fs-label)] font-medium transition-colors hover:brightness-110 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1"
|
||||
style={{ background: size === item.value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
style={{ background: normalizeRatioValue(config.size) === value ? theme.toolbar.activeBg : "transparent", color: theme.node.text, outlineColor: theme.node.muted }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onClick={() => onConfigChange("size", item.value)}
|
||||
onClick={() => onConfigChange("size", value)}
|
||||
>
|
||||
<SizePreview width={item.width} height={item.height} color={theme.node.text} />
|
||||
<span>{item.label}</span>
|
||||
<SizePreview width={ratioPreview(value).width} height={ratioPreview(value).height} color={theme.node.text} />
|
||||
<span>{value}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${secondOptions.length}, minmax(0, 1fr)) 72px` }}>
|
||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${durationColumns}, minmax(0, 1fr))` }}>
|
||||
{secondOptions.map((value) => (
|
||||
<OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
|
||||
{value}s
|
||||
</OptionPill>
|
||||
))}
|
||||
<DurationInput value={Number(seconds)} min={VIDEO_DURATION_MIN} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
{profile.duration.selection === "range" ? <DurationInput value={Number(seconds)} min={profile.duration.min || VIDEO_DURATION_MIN} max={profile.duration.max} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> : null}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
{profile.generateAudio.supported || profile.watermark.supported ? <SettingGroup title="输出" color={theme.node.muted}><div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}</div></SettingGroup> : null}
|
||||
</div>
|
||||
</ImageSettingsTheme>
|
||||
);
|
||||
}
|
||||
|
||||
function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
|
||||
function JiMengVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
|
||||
const seconds = normalizeVideoDuration(config.videoSeconds);
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
@@ -105,12 +107,12 @@ function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, cl
|
||||
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="比例" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{jiMengRatioOptions.map((value) => <OptionPill key={value} selected={config.size === value} theme={theme} onClick={() => onConfigChange("size", value)}>{value}</OptionPill>)}
|
||||
{profile.ratios.map((value) => <OptionPill key={value} selected={config.size === value} theme={theme} onClick={() => onConfigChange("size", value)}>{value}</OptionPill>)}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="秒数" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{[5, 10].map((value) => <OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>{value}s</OptionPill>)}
|
||||
{videoDurationOptions(profile).map((value) => <OptionPill key={value} selected={seconds === String(value)} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>{value}s</OptionPill>)}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
@@ -118,13 +120,15 @@ function JiMengVideoSettingsPanel({ config, onConfigChange, theme, showTitle, cl
|
||||
);
|
||||
}
|
||||
|
||||
function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps) {
|
||||
function SeedanceVideoSettingsPanel({ config, profile, onConfigChange, theme, showTitle, className }: VideoSettingsPanelProps & { profile: VideoCapabilityConfig }) {
|
||||
const model = modelOptionName(config.model || config.videoModel);
|
||||
const resolution = normalizeSeedanceResolution(config.vquality, model);
|
||||
const ratio = normalizeSeedanceRatio(config.size);
|
||||
const duration = normalizeSeedanceDuration(config.videoSeconds);
|
||||
const generateAudio = boolConfig(config.videoGenerateAudio, true);
|
||||
const watermark = boolConfig(config.videoWatermark, false);
|
||||
const generateAudio = boolConfig(config.videoGenerateAudio, profile.generateAudio.default);
|
||||
const watermark = boolConfig(config.videoWatermark, profile.watermark.default);
|
||||
const durationOptions = videoDurationOptions(profile);
|
||||
const durationColumns = durationOptions.length + (profile.duration.selection === "range" ? 1 : 0);
|
||||
|
||||
return (
|
||||
<ImageSettingsTheme theme={theme}>
|
||||
@@ -132,7 +136,8 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
{showTitle ? <div className="text-sm font-semibold">视频设置</div> : null}
|
||||
<SettingGroup title="分辨率" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{seedanceResolutionOptions.map((item) => {
|
||||
{profile.resolutions.map((value) => {
|
||||
const item = { value, label: value.toUpperCase() };
|
||||
const disabled = item.value === "1080p" && isSeedanceFastModel(model);
|
||||
return (
|
||||
<OptionPill key={item.value} selected={resolution === item.value} disabled={disabled} theme={theme} onClick={() => onConfigChange("vquality", item.value)}>
|
||||
@@ -145,7 +150,9 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
</SettingGroup>
|
||||
<SettingGroup title="比例" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{seedanceRatioOptions.map((item) => (
|
||||
{profile.ratios.map((value) => {
|
||||
const item = { value, label: value };
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
@@ -159,23 +166,24 @@ function SeedanceVideoSettingsPanel({ config, onConfigChange, theme, showTitle,
|
||||
</span>
|
||||
<span className="whitespace-nowrap">{item.label}</span>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="时长" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-[repeat(4,minmax(0,1fr))_72px] gap-1.5">
|
||||
{seedanceDurationOptions.map((value) => (
|
||||
<div className="grid gap-1.5" style={{ gridTemplateColumns: `repeat(${durationColumns}, minmax(0, 1fr))` }}>
|
||||
{durationOptions.map((value) => (
|
||||
<OptionPill key={value} selected={duration === value} theme={theme} onClick={() => onConfigChange("videoSeconds", String(value))}>
|
||||
{value}s
|
||||
</OptionPill>
|
||||
))}
|
||||
<DurationInput value={duration} min={VIDEO_DURATION_MIN} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} />
|
||||
{profile.duration.selection === "range" ? <DurationInput value={duration} min={profile.duration.min || VIDEO_DURATION_MIN} max={profile.duration.max} theme={theme} onChange={(value) => onConfigChange("videoSeconds", String(value))} /> : null}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
<SettingGroup title="输出" color={theme.node.muted}>
|
||||
<div className="grid grid-cols-2 gap-3 rounded-md px-2" style={{ background: theme.toolbar.itemHover }}>
|
||||
<SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} />
|
||||
<SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} />
|
||||
{profile.generateAudio.supported ? <SwitchRow label="生成声音" checked={generateAudio} theme={theme} onChange={(checked) => onConfigChange("videoGenerateAudio", String(checked))} /> : null}
|
||||
{profile.watermark.supported ? <SwitchRow label="添加水印" checked={watermark} theme={theme} onChange={(checked) => onConfigChange("videoWatermark", String(checked))} /> : null}
|
||||
</div>
|
||||
</SettingGroup>
|
||||
</div>
|
||||
@@ -239,9 +247,9 @@ function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix:
|
||||
);
|
||||
}
|
||||
|
||||
function DurationInput({ value, min, theme, onChange }: { value: number; min: number; theme: CanvasTheme; onChange: (value: number) => void }) {
|
||||
function DurationInput({ value, min, max, theme, onChange }: { value: number; min: number; max?: number; theme: CanvasTheme; onChange: (value: number) => void }) {
|
||||
const commit = (input: HTMLInputElement) => {
|
||||
const next = Math.max(min, Math.floor(Number(input.value) || value || min));
|
||||
const next = Math.min(max || Number.POSITIVE_INFINITY, Math.max(min, Math.floor(Number(input.value) || value || min)));
|
||||
input.value = String(next);
|
||||
onChange(next);
|
||||
};
|
||||
@@ -253,6 +261,7 @@ function DurationInput({ value, min, theme, onChange }: { value: number; min: nu
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={min}
|
||||
max={max}
|
||||
defaultValue={value}
|
||||
aria-label="视频时长(秒)"
|
||||
className="min-w-0 flex-1 bg-transparent pl-2 text-right outline-none [appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
|
||||
@@ -303,3 +312,9 @@ function readSizeDimensions(size: string) {
|
||||
const match = size.match(/^(\d+)x(\d+)$/);
|
||||
return { width: Number(match?.[1]) || 1280, height: Number(match?.[2]) || 720 };
|
||||
}
|
||||
|
||||
function normalizeRatioValue(value: string) {
|
||||
const match = String(value || "").match(/^(\d+(?:\.\d+)?)x(\d+(?:\.\d+)?)$/);
|
||||
if (!match) return value;
|
||||
return `${match[1]}:${match[2]}`;
|
||||
}
|
||||
|
||||
@@ -33,16 +33,17 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
const elevatedBackground = dark ? "rgba(31, 31, 32, 0.96)" : "rgba(255, 255, 255, 0.96)";
|
||||
const subtleBackground = dark ? "rgba(255, 255, 255, 0.055)" : "rgba(17, 17, 17, 0.035)";
|
||||
const interactiveBorder = dark ? "rgba(255, 255, 255, 0.18)" : "rgba(17, 17, 17, 0.18)";
|
||||
const focusShadow = dark ? "0 0 0 3px rgba(255, 255, 255, 0.12)" : "0 0 0 3px rgba(17, 17, 17, 0.08)";
|
||||
// 黑白主题使用边框表达焦点,避免输入控件周围出现蓝紫色光圈。
|
||||
const focusShadow = "none";
|
||||
// 暗色界面的黑白主按钮与选择控件分色,避免开关轨道、勾选符号和滑块融成一片。
|
||||
const selectionControl = dark
|
||||
? {
|
||||
active: "#4657c5",
|
||||
active: "#ffffff",
|
||||
border: "rgba(255, 255, 255, 0.3)",
|
||||
disabledBackground: "rgba(255, 255, 255, 0.06)",
|
||||
focus: "rgba(116, 133, 238, 0.28)",
|
||||
hover: "#7485ee",
|
||||
primary: "#5b6ee1",
|
||||
focus: "transparent",
|
||||
hover: "#e5e5e5",
|
||||
primary: "#f5f5f5",
|
||||
surface: "rgba(255, 255, 255, 0.035)",
|
||||
}
|
||||
: null;
|
||||
@@ -180,7 +181,7 @@ export function getAntThemeConfig(dark: boolean): ThemeConfig {
|
||||
multipleItemHeight: 24,
|
||||
activeBorderColor: interactiveBorder,
|
||||
hoverBorderColor: interactiveBorder,
|
||||
activeOutlineColor: dark ? "rgba(255, 255, 255, 0.12)" : "rgba(17, 17, 17, 0.08)",
|
||||
activeOutlineColor: "transparent",
|
||||
optionActiveBg: color.selectActiveBg,
|
||||
optionSelectedBg: color.selectSelectedBg,
|
||||
optionSelectedColor: color.selectText,
|
||||
|
||||
121
web/src/lib/model-capabilities.ts
Normal file
121
web/src/lib/model-capabilities.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
|
||||
export type ModelCapabilityConfig = {
|
||||
version: number;
|
||||
video?: VideoCapabilityConfig;
|
||||
};
|
||||
|
||||
export type VideoCapabilityConfig = {
|
||||
references: {
|
||||
promptMaxChars: number;
|
||||
maxImages: number;
|
||||
maxImageBytes: number;
|
||||
maxVideos: number;
|
||||
maxVideoBytes: number;
|
||||
maxVideoDurationSeconds: number;
|
||||
maxAudios: number;
|
||||
maxAudioBytes: number;
|
||||
maxAudioDurationSeconds: number;
|
||||
};
|
||||
duration: {
|
||||
selection: "range" | "enum";
|
||||
min?: number;
|
||||
max?: number;
|
||||
step?: number;
|
||||
values?: number[];
|
||||
default: number;
|
||||
};
|
||||
ratios: string[];
|
||||
defaultRatio: string;
|
||||
resolutions: string[];
|
||||
defaultResolution: string;
|
||||
generateAudio: { supported: boolean; default: boolean };
|
||||
watermark: { supported: boolean; default: boolean };
|
||||
operations: string[];
|
||||
defaultOperation: string;
|
||||
};
|
||||
|
||||
export function defaultModelCapabilityConfig(protocol?: ModelProtocol): ModelCapabilityConfig {
|
||||
const video: VideoCapabilityConfig = {
|
||||
references: {
|
||||
promptMaxChars: 1000,
|
||||
maxImages: 9,
|
||||
maxImageBytes: 30 * 1024 * 1024,
|
||||
maxVideos: 0,
|
||||
maxVideoBytes: 0,
|
||||
maxVideoDurationSeconds: 0,
|
||||
maxAudios: 0,
|
||||
maxAudioBytes: 0,
|
||||
maxAudioDurationSeconds: 0,
|
||||
},
|
||||
duration: { selection: "range", min: 1, max: 15, step: 1, default: 6 },
|
||||
ratios: ["16:9", "9:16", "1:1", "4:3", "3:4", "21:9"],
|
||||
defaultRatio: "16:9",
|
||||
resolutions: ["480p", "720p", "1080p", "2160p"],
|
||||
defaultResolution: "720p",
|
||||
generateAudio: { supported: false, default: false },
|
||||
watermark: { supported: false, default: false },
|
||||
operations: ["text_to_video", "image_to_video"],
|
||||
defaultOperation: "text_to_video",
|
||||
};
|
||||
if (protocol === "volcengine-jimeng-video") video.duration = { selection: "enum", values: [5, 10], default: 5 };
|
||||
if (protocol === "gemini-veo") {
|
||||
video.duration = { selection: "enum", values: [4, 6, 8], default: 6 };
|
||||
video.resolutions = ["720p", "1080p"];
|
||||
}
|
||||
if (protocol === "volcengine-ark-video" || protocol === "newapi-channel-1" || protocol === "newapi-channel-2") {
|
||||
video.references.maxVideos = 3;
|
||||
video.references.maxAudios = 3;
|
||||
video.references.maxVideoBytes = 200 * 1024 * 1024;
|
||||
video.references.maxAudioBytes = 15 * 1024 * 1024;
|
||||
video.references.maxVideoDurationSeconds = 15;
|
||||
video.references.maxAudioDurationSeconds = 15;
|
||||
video.generateAudio = { supported: true, default: true };
|
||||
}
|
||||
if (protocol === "volcengine-ark-video") video.watermark = { supported: true, default: false };
|
||||
return { version: 1, video };
|
||||
}
|
||||
|
||||
export function modelCapabilityConfigFor(config: { channels: Array<{ id: string; models: string[]; modelCosts?: Array<{ model: string; capabilityConfig?: ModelCapabilityConfig; protocol?: ModelProtocol }> }> }, model: string) {
|
||||
const separator = model.indexOf("::");
|
||||
const channelId = separator >= 0 ? model.slice(0, separator) : "";
|
||||
const modelName = separator >= 0 ? model.slice(separator + 2) : model;
|
||||
const channel = config.channels.find((item) => item.id === channelId) || config.channels.find((item) => item.models.includes(modelName));
|
||||
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
|
||||
return cost?.capabilityConfig || defaultModelCapabilityConfig(cost?.protocol);
|
||||
}
|
||||
|
||||
export function normalizeVideoValue(profile: VideoCapabilityConfig, value: { seconds?: string; ratio?: string; resolution?: string }) {
|
||||
const duration = profile.duration.selection === "enum"
|
||||
? (profile.duration.values || []).includes(Number(value.seconds)) ? Number(value.seconds) : profile.duration.default
|
||||
: normalizeRangeDuration(profile, Number(value.seconds));
|
||||
const ratio = profile.ratios.includes(value.ratio || "") ? value.ratio! : profile.defaultRatio;
|
||||
const resolution = profile.resolutions.includes(value.resolution || "") ? value.resolution! : profile.defaultResolution;
|
||||
return { seconds: String(duration), ratio, resolution };
|
||||
}
|
||||
|
||||
function normalizeRangeDuration(profile: VideoCapabilityConfig, value: number) {
|
||||
const min = profile.duration.min || 1;
|
||||
const max = profile.duration.max || min;
|
||||
const step = profile.duration.step || 1;
|
||||
const candidate = Number.isFinite(value) ? Math.floor(value) : profile.duration.default;
|
||||
const clamped = Math.min(max, Math.max(min, candidate));
|
||||
const maxStep = Math.max(0, Math.floor((max - min) / step));
|
||||
return min + Math.min(maxStep, Math.max(0, Math.round((clamped - min) / step))) * step;
|
||||
}
|
||||
|
||||
export function videoDurationOptions(profile: VideoCapabilityConfig) {
|
||||
if (profile.duration.selection === "enum") return profile.duration.values || [];
|
||||
const min = profile.duration.min || 1;
|
||||
const max = profile.duration.max || min;
|
||||
const step = profile.duration.step || 1;
|
||||
return Array.from({ length: Math.floor((max - min) / step) + 1 }, (_, index) => min + index * step);
|
||||
}
|
||||
|
||||
export function videoDurationAllowed(profile: VideoCapabilityConfig, value: number) {
|
||||
if (profile.duration.selection === "enum") return (profile.duration.values || []).includes(value);
|
||||
const min = profile.duration.min || 1;
|
||||
const max = profile.duration.max || min;
|
||||
const step = profile.duration.step || 1;
|
||||
return value >= min && value <= max && (value - min) % step === 0;
|
||||
}
|
||||
@@ -21,6 +21,7 @@ export async function testChannelModelConnection(channel: ModelChannel, model: s
|
||||
protocol: testProtocol,
|
||||
billingMode: modelCost?.billingMode || "fixed_request",
|
||||
unitPriceMicrocredits: modelCost?.unitPriceMicrocredits || 0,
|
||||
capabilityConfig: modelCost?.capabilityConfig,
|
||||
},
|
||||
...(channel.modelCosts || []).filter((item) => item.model !== model),
|
||||
],
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function ChannelsPage() {
|
||||
<TableSurface>
|
||||
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={6} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 820 }} />}
|
||||
</TableSurface>
|
||||
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} size="min(560px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
|
||||
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} size="min(720px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}><Input placeholder="例如:OpenAI 官方渠道" /></Form.Item>
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function AdminPageFrame({ title, description, actions, back, children }:
|
||||
|
||||
function MobileAdminNavigation() {
|
||||
return (
|
||||
<nav className="hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 bg-background px-3 py-2 lg:hidden" aria-label="管理后台分区">
|
||||
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
|
||||
{adminNavigation.flatMap((group) => group.items).map((item) => (
|
||||
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-foreground/[.05] hover:text-foreground")}>
|
||||
{item.icon}<span>{item.label}</span>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AudioLines, Check, Film, Flame, FlaskConical, Image, MessageSquareText, Network, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
|
||||
type EditableCapability = Exclude<ChannelModel["capability"], "">;
|
||||
type EditableCapability = ModelCapabilityChoice;
|
||||
|
||||
type FormValues = {
|
||||
modelKey: string;
|
||||
@@ -24,6 +26,7 @@ type FormValues = {
|
||||
outputTokenPrice: number;
|
||||
cachedTokenPrice: number;
|
||||
enabled: boolean;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
};
|
||||
|
||||
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||
@@ -86,13 +89,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const startCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true });
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true, capabilityConfig: undefined });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: ChannelModel) => {
|
||||
setEditing(item);
|
||||
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled });
|
||||
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled, capabilityConfig: item.capability === "video" ? item.capabilityConfig || defaultModelCapabilityConfig(item.protocol) : undefined });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -112,6 +115,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: true,
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig: values.capability === "video" ? values.capabilityConfig : undefined,
|
||||
};
|
||||
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
|
||||
else await createAdminChannelModel(channel.id, payload);
|
||||
@@ -128,13 +132,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
};
|
||||
|
||||
const testModel = async () => {
|
||||
const values = await form.validateFields(["modelKey", "capability", "protocol"]);
|
||||
const values = await form.validateFields(["modelKey", "capability", "protocol", ...(modelCapability === "video" ? ["capabilityConfig"] : [])]);
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminChannelModel(channel.id, {
|
||||
modelKey: values.modelKey.trim(),
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
capabilityConfig: values.capabilityConfig,
|
||||
});
|
||||
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
|
||||
} catch (error) {
|
||||
@@ -156,6 +161,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (changed: Partial<FormValues>) => {
|
||||
if (changed.protocol && modelCapability === "video") {
|
||||
form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol).video ? defaultModelCapabilityConfig(changed.protocol) : undefined);
|
||||
}
|
||||
if (!changed.capability) return;
|
||||
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
|
||||
if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) {
|
||||
@@ -163,7 +171,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if (modelProtocolCapability(current) !== changed.capability) {
|
||||
form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value);
|
||||
const nextProtocol = MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value;
|
||||
form.setFieldValue("protocol", nextProtocol);
|
||||
form.setFieldValue("capabilityConfig", changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,6 +256,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} />
|
||||
</Form.Item>
|
||||
{modelCapability === "video" ? <Form.Item name="capabilityConfig" rules={[{ required: true, message: "请配置视频能力参数" }]}><ModelCapabilityEditor protocol={form.getFieldValue("protocol")} /></Form.Item> : null}
|
||||
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
|
||||
</Form.Item>
|
||||
@@ -282,118 +293,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
);
|
||||
}
|
||||
|
||||
const capabilityChoices: Array<{
|
||||
value: EditableCapability;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
brands: string[];
|
||||
}> = [
|
||||
{ value: "text", label: "文本", description: "对话与推理", icon: <MessageSquareText className="size-4" />, brands: ["openai", "deepseek", "glm"] },
|
||||
{ value: "image", label: "图片", description: "生成与编辑", icon: <Image className="size-4" />, brands: ["openai", "gemini"] },
|
||||
{ value: "video", label: "视频", description: "生成与续写", icon: <Film className="size-4" />, brands: ["grok", "gemini"] },
|
||||
{ value: "audio", label: "音频", description: "语音与音效", icon: <AudioLines className="size-4" />, brands: ["openai"] },
|
||||
];
|
||||
|
||||
function CapabilityCardPicker({ value, onChange }: { value?: EditableCapability; onChange?: (value: EditableCapability) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="模型能力">
|
||||
{capabilityChoices.map((item) => {
|
||||
const selected = value === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => onChange?.(item.value)}
|
||||
>
|
||||
<span className={cn("grid size-8 place-items-center rounded-md", selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/65")}>{item.icon}</span>
|
||||
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
|
||||
<span className="mt-2 block text-sm font-semibold">{item.label}</span>
|
||||
<span className="block text-xs text-foreground/48">{item.description}</span>
|
||||
<BrandIconRow models={item.brands} className="mt-auto pt-2" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolCardPicker({ capability, value, onChange }: { capability?: EditableCapability; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void }) {
|
||||
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" role="radiogroup" aria-label="模型请求协议">
|
||||
{protocols.map((protocol) => {
|
||||
const selected = value === protocol.value;
|
||||
return (
|
||||
<button
|
||||
key={protocol.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => onChange?.(protocol.value)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5 pr-6">
|
||||
<ProtocolBrandMark protocol={protocol} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{protocol.label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/48">{protocol.create}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-foreground/58">{protocol.media}</div>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-2 text-[var(--fs-tiny)] text-foreground/42">
|
||||
<span className="truncate">{protocol.contentType}</span>
|
||||
{protocol.poll ? <span className="shrink-0">异步轮询</span> : <span className="shrink-0">同步响应</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolBrandMark({ protocol }: { protocol: ModelProtocolDefinition }) {
|
||||
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact />;
|
||||
if (protocol.value.startsWith("volcengine-jimeng-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Sparkles className="size-4" /></span>;
|
||||
if (protocol.value.startsWith("volcengine-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Flame className="size-4" /></span>;
|
||||
if (protocol.value.startsWith("newapi-channel-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Network className="size-4" /></span>;
|
||||
const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai";
|
||||
return <BrandIconRow models={[brand]} compact />;
|
||||
}
|
||||
|
||||
function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center", compact ? "shrink-0 -space-x-1" : "-space-x-1", className)} aria-hidden="true">
|
||||
{models.map((model) => (
|
||||
<span key={model} className={cn("grid shrink-0 place-items-center rounded-md border border-border/70 bg-background", compact ? "size-8" : "size-6")} title={modelBrandLabel(model)}>
|
||||
<ModelIcon model={model} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function modelBrandLabel(model: string) {
|
||||
const labels: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
deepseek: "DeepSeek",
|
||||
glm: "智谱 GLM",
|
||||
gemini: "Google Gemini",
|
||||
grok: "xAI Grok",
|
||||
};
|
||||
return labels[model] || model;
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ChannelModel["capability"]) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function AdminPage() {
|
||||
if (!hydrated) return null;
|
||||
if (actor?.role !== "admin") {
|
||||
return (
|
||||
<main className="min-h-dvh bg-background px-6 py-10 text-foreground">
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-border bg-background p-6">
|
||||
<main className="app-workspace-page min-h-dvh px-6 py-10 text-foreground">
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-border bg-[var(--workspace-surface)] p-6">
|
||||
<h1 className="text-2xl font-semibold">无权限</h1>
|
||||
<p className="mt-2 text-sm text-foreground/55">当前账号不是管理员,无法访问后台。</p>
|
||||
</div>
|
||||
|
||||
@@ -1684,7 +1684,7 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
|
||||
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef} panelWidth={624}>
|
||||
{renderCanvasNodePanel(dialogNode)}
|
||||
</CanvasNodePanelOverlay>
|
||||
) : null}
|
||||
|
||||
@@ -38,6 +38,9 @@ export function creationAttachmentFromImage(file: File, uploaded: UploadedImage)
|
||||
dataUrl: uploaded.url,
|
||||
url: uploaded.url,
|
||||
storageKey: uploaded.storageKey,
|
||||
bytes: uploaded.bytes,
|
||||
width: uploaded.width,
|
||||
height: uploaded.height,
|
||||
previewUrl: uploaded.url,
|
||||
};
|
||||
}
|
||||
@@ -51,6 +54,9 @@ export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachme
|
||||
dataUrl: url,
|
||||
url,
|
||||
storageKey: asset.data.storageKey,
|
||||
bytes: asset.data.bytes,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
previewUrl: url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-referen
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
@@ -128,11 +129,20 @@ export default function CreatePage() {
|
||||
[activeId, conversations],
|
||||
);
|
||||
const selectedModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel;
|
||||
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
|
||||
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
|
||||
const isEmpty = !activeConversation?.messages.length;
|
||||
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
|
||||
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "video") return;
|
||||
const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` });
|
||||
setSeconds(normalized.seconds);
|
||||
setRatio(normalized.ratio);
|
||||
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
|
||||
}, [mode, selectedModel, videoProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void localforage.getItem<CreationConversation[]>(STORAGE_KEY).then((stored) => {
|
||||
@@ -228,7 +238,12 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
const addAttachments = (files: FileList | File[]) => {
|
||||
const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, 6 - attachments.length));
|
||||
const maxImages = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : 6;
|
||||
if (mode === "video" && maxImages === 0) {
|
||||
toast.warning("当前模型不支持图生视频");
|
||||
return;
|
||||
}
|
||||
const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, maxImages - attachments.length));
|
||||
if (!next.length) return;
|
||||
void Promise.allSettled(next.map(async (file) => {
|
||||
const uploaded = await uploadImage(file);
|
||||
@@ -238,7 +253,7 @@ export default function CreatePage() {
|
||||
})).then((settled) => {
|
||||
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
const failed = settled.filter((entry) => entry.status === "rejected");
|
||||
if (items.length) setAttachments((current) => [...current, ...items].slice(0, 6));
|
||||
if (items.length) setAttachments((current) => [...current, ...items].slice(0, mode === "video" ? videoProfile.references.maxImages : 6));
|
||||
if (failed.length) toast.error(`${failed.length} 张参考图片上传失败,请重试`);
|
||||
});
|
||||
};
|
||||
@@ -261,9 +276,21 @@ export default function CreatePage() {
|
||||
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
||||
return;
|
||||
}
|
||||
if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) {
|
||||
toast.error("当前模型不支持所选视频时长,请重新选择");
|
||||
return;
|
||||
}
|
||||
if (mode === "video" && Array.from(text).length > videoProfile.references.promptMaxChars) {
|
||||
toast.error(`提示词超过当前模型限制(最多 ${videoProfile.references.promptMaxChars} 字)`);
|
||||
return;
|
||||
}
|
||||
const settings = { ratio, seconds, quality, videoQuality, count };
|
||||
const references = selectedCreationReferences(text, mentionReferences);
|
||||
const expandedPrompt = expandCreationPrompt(text, references, attachments);
|
||||
if (mode === "video" && Array.from(expandedPrompt).length > videoProfile.references.promptMaxChars) {
|
||||
toast.error(`引用展开后提示词超过当前模型限制(最多 ${videoProfile.references.promptMaxChars} 字)`);
|
||||
return;
|
||||
}
|
||||
const referenceMetadata = creationReferenceMetadata(references);
|
||||
followLatestMessageRef.current = true;
|
||||
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
|
||||
@@ -446,6 +473,7 @@ export default function CreatePage() {
|
||||
onFileChange: handleFileChange,
|
||||
onModeChange: selectMode,
|
||||
model: selectedModel,
|
||||
videoProfile,
|
||||
config,
|
||||
onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value),
|
||||
ratio,
|
||||
@@ -485,7 +513,7 @@ export default function CreatePage() {
|
||||
}
|
||||
|
||||
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) {
|
||||
return <Drawer open={open} onClose={onClose} placement="right" size="min(360px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
return <Drawer open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
<ol className="creation-history-timeline" aria-label="历史对话,按更新时间倒序排列">
|
||||
{conversations.map((conversation) => {
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
@@ -553,6 +581,7 @@ type ComposerProps = {
|
||||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onModeChange: (mode: CreationMode) => void;
|
||||
model: string;
|
||||
videoProfile: VideoCapabilityConfig;
|
||||
config: ReturnType<typeof useEffectiveConfig>;
|
||||
onModelChange: (value: string) => void;
|
||||
ratio: string;
|
||||
@@ -580,9 +609,9 @@ function CreationComposer(props: ComposerProps) {
|
||||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept="image/*" multiple onChange={props.onFileChange} />
|
||||
<Tooltip title="添加参考图片"><button type="button" className="creation-chat-reference is-paper" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy} aria-label="添加参考图片"><Plus /><span>参考内容</span></button></Tooltip>
|
||||
<Tooltip title={props.mode === "video" && !props.videoProfile.operations.includes("image_to_video") ? "当前模型不支持图生视频" : "添加参考图片"}><button type="button" className="creation-chat-reference is-paper" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || (props.mode === "video" && !props.videoProfile.operations.includes("image_to_video"))} aria-label="添加参考图片"><Plus /><span>参考内容</span></button></Tooltip>
|
||||
<div className="creation-chat-editor">
|
||||
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} maxLength={props.mode === "video" ? props.videoProfile.references.promptMaxChars : undefined} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment"><img src={item.previewUrl} alt={item.name} /><button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,7 +620,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
|
||||
{props.mode !== "text" ? <GenerationSettingsMenu {...props} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu model={props.model} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
</div>
|
||||
{props.busy ? <button type="button" className="creation-chat-submit is-stopping" onClick={props.onStop} aria-label="停止生成"><Square className="size-3.5 fill-current" /></button> : <button type="button" className="creation-chat-submit" disabled={!canSubmit} onClick={props.onSubmit} aria-label="发送"><ArrowUp className="size-4" /></button>}
|
||||
</footer>
|
||||
@@ -615,10 +644,12 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio));
|
||||
const qualityLabel = qualityOptions.find((item) => item.value === props.quality)?.label || "自动";
|
||||
const ratios = props.mode === "video" ? props.videoProfile.ratios : ratioOptions.map((item) => item.value);
|
||||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||||
const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : `${props.ratio} · ${qualityLabel} · ${props.count}`;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
<SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratioOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.ratio} className={option.value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(option.value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(option.value)} /></span><span>{option.value}</span></button>)}</div>{customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>}</div></SettingSection>
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutionOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
<SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div>{props.mode !== "video" && (customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>)}</div></SettingSection>
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
<SettingSection title="图片质量" value={qualityLabel}><div className="creation-choice-grid is-quality">{qualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection>
|
||||
<SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(event.target.value)} aria-label="生成数量,范围 1 到 15" /><em>张</em></label></div></SettingSection>
|
||||
</>}
|
||||
@@ -632,11 +663,13 @@ function SettingSection({ title, value, children }: { title: string; value?: str
|
||||
return <section className="creation-parameter-section"><header><h3>{title}</h3>{value ? <span>{value}</span> : null}</header>{children}</section>;
|
||||
}
|
||||
|
||||
function DurationMenu({ model, seconds, onChange }: { model: string; seconds: string; onChange: (value: string) => void }) {
|
||||
function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const value = Math.max(1, Math.floor(Number(seconds) || 6));
|
||||
const presets = durationPresets(model);
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div><div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div><label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min="1" step="1" inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label></div>}>
|
||||
const value = Math.max(1, Math.floor(Number(seconds) || profile.duration.default));
|
||||
const presets = videoDurationOptions(profile);
|
||||
const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...presets);
|
||||
const max = profile.duration.selection === "range" ? profile.duration.max || min : Math.max(...presets);
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div><div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div>{profile.duration.selection === "range" ? <label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min={min} max={max} step={profile.duration.step || 1} inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label> : null}</div>}>
|
||||
<button type="button" className="creation-chat-control is-duration" aria-label={`视频时长:${value}秒`}><Clock3 /><span>{value}s</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
@@ -646,13 +679,6 @@ function CreationIntro({ mode }: { mode: CreationMode }) {
|
||||
return <header className="creation-chat-intro" aria-live="polite"><span className="creation-intro-signal" aria-hidden="true" /><h1>{copy[0]}<span>{copy[1]}</span>{copy[2]}</h1><p>{copy[3]}</p></header>;
|
||||
}
|
||||
|
||||
function durationPresets(model: string) {
|
||||
const name = modelOptionName(model).toLowerCase();
|
||||
if (name.includes("veo")) return [4, 6, 8];
|
||||
if (name.includes("seedance")) return [4, 5, 8, 10, 15];
|
||||
return [5, 10, 15, 20, 30];
|
||||
}
|
||||
|
||||
function videoResolutionLabel(value: string | number) {
|
||||
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background px-6 py-10 text-stone-900 dark:text-stone-100">
|
||||
<div className="app-workspace-page flex h-dvh flex-col overflow-hidden text-foreground">
|
||||
<main className="app-workspace-page flex h-full min-h-0 items-center justify-center overflow-y-auto px-6 py-10 text-foreground">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<WorkspaceSignalIcon variant="empty" size="lg" className="mx-auto mb-5" />
|
||||
<div className="mb-2 text-xs font-semibold tabular-nums text-foreground/45">404</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function RouteErrorPage() {
|
||||
const message = error instanceof Error ? error.message : "页面暂时无法显示";
|
||||
|
||||
return (
|
||||
<main className="grid h-dvh place-items-center bg-background px-6 text-foreground">
|
||||
<main className="app-workspace-page grid h-dvh place-items-center px-6 text-foreground">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<WorkspaceSignalIcon variant="error" size="lg" className="mx-auto" />
|
||||
<p className="text-xs font-medium text-muted-foreground">页面运行异常</p>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { App, Button, InputNumber, Segmented, Select } from "antd";
|
||||
import { App, Button, InputNumber, Segmented } from "antd";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { testChannelModelConnection } from "@/lib/model-connection-test";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { ModelCapabilityProtocolModal } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
@@ -20,6 +23,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
protocol: defaultProtocolForModel(channel, model),
|
||||
billingMode: "fixed_request" as const,
|
||||
unitPriceMicrocredits: 0,
|
||||
capabilityConfig: defaultModelCapabilityConfig(defaultProtocolForModel(channel, model)),
|
||||
};
|
||||
const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model }];
|
||||
onChange(next.filter((item) => channel.models.includes(item.model)));
|
||||
@@ -40,10 +44,10 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
return (
|
||||
<div className="mt-3 border-t border-border/70 pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">测试会发起真实请求并可能产生供应商费用;视频价格仅用于成本记录</div></div>
|
||||
<div><div className="text-xs font-medium">模型能力与请求协议</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">与运营后台使用同一能力目录;测试会发起真实请求并可能产生供应商费用</div></div>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/35">{channel.models.length} 个模型</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60 rounded-md border border-border/70">
|
||||
<div className="space-y-2">
|
||||
{channel.models.map((rawModel) => {
|
||||
const model = modelOptionName(rawModel);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === model);
|
||||
@@ -51,17 +55,19 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
return (
|
||||
<div key={model} className="grid gap-2 px-2.5 py-2.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px_96px] lg:items-center">
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<Select<ModelProtocol>
|
||||
size="small"
|
||||
value={protocol}
|
||||
options={MODEL_PROTOCOL_OPTIONS}
|
||||
onChange={(value) => updateCost(model, { protocol: value, capability: modelProtocolCapability(value) || capability, billingMode: modelProtocolCapability(value) === "video" ? billingMode : "fixed_request" })}
|
||||
/>
|
||||
{capability === "video" ? <Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : <span className="text-center text-[var(--fs-tiny)] text-foreground/35">{modelProtocolLabel(protocol)}</span>}
|
||||
{capability === "video" ? <InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /> : <span />}
|
||||
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
|
||||
<div key={model} className="rounded-md border border-border/70 bg-background/45 p-2.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<ModelCapabilityProtocolModal
|
||||
value={{ capability, protocol }}
|
||||
onChange={({ capability: nextCapability, protocol: nextProtocol }) => updateCost(model, { protocol: nextProtocol, capability: nextCapability, billingMode: nextCapability === "video" ? billingMode : "fixed_request", capabilityConfig: nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined })}
|
||||
/>
|
||||
{capability === "video" ? <div className="grid gap-2 lg:grid-cols-[176px_1fr]"><Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /><InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /></div> : null}
|
||||
{capability === "video" ? <ModelCapabilityEditor value={cost?.capabilityConfig || defaultModelCapabilityConfig(protocol)} protocol={protocol} onChange={(capabilityConfig) => updateCost(model, { capabilityConfig })} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Select, Tag, Tooltip } from "antd";
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Segmented, Select, Tag, Tooltip } from "antd";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
@@ -8,19 +8,16 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolLabel } from "@/lib/model-protocols";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import {
|
||||
createModelChannel,
|
||||
defaultBaseUrlForApiFormat,
|
||||
defaultBaseUrlForChannelInterface,
|
||||
defaultConfig,
|
||||
filterModelsByCapability,
|
||||
modelOptionsFromChannels,
|
||||
useConfigStore,
|
||||
type AiConfig,
|
||||
type ChannelInterfaceType,
|
||||
type ModelChannel,
|
||||
} from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -38,13 +35,7 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
|
||||
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
];
|
||||
|
||||
type UserChannelProtocol = ChannelInterfaceType | "auto" | "gemini";
|
||||
|
||||
const channelProtocolOptions = [
|
||||
{ label: "OpenAI 自动兼容", value: "auto" },
|
||||
{ label: "Google Gemini(模型级选择)", value: "gemini" },
|
||||
...MODEL_PROTOCOL_OPTIONS,
|
||||
];
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
|
||||
function isConfigSection(value: string | null): value is ConfigSectionKey {
|
||||
return configSections.some((section) => section.key === value);
|
||||
@@ -131,12 +122,12 @@ export default function SettingsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChannelProtocol = (channel: ModelChannel, protocol: UserChannelProtocol) => {
|
||||
const apiFormat = protocol === "gemini" || protocol === "gemini-veo" ? "gemini" : "openai";
|
||||
const interfaceType = protocol === "auto" || protocol === "gemini" ? undefined : protocol;
|
||||
const defaultBaseUrl = protocol === "gemini" ? defaultBaseUrlForApiFormat("gemini") : defaultBaseUrlForChannelInterface(interfaceType);
|
||||
const updateChannelConnection = (channel: ModelChannel, connection: UserChannelConnection) => {
|
||||
const apiFormat = connection;
|
||||
const defaultBaseUrl = defaultBaseUrlForApiFormat(apiFormat);
|
||||
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
|
||||
updateChannel(channel.id, { apiFormat, interfaceType, baseUrl });
|
||||
// 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。
|
||||
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
@@ -253,7 +244,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 flex-col bg-[var(--workspace-canvas)] text-foreground">
|
||||
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
|
||||
<header className="flex min-h-16 shrink-0 items-center justify-between gap-4 border-b border-border/70 px-4 py-3 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{shouldPromptContinue ? (
|
||||
@@ -290,7 +281,7 @@ export default function SettingsPage() {
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-transparent">
|
||||
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
|
||||
<div className={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-[1180px]"}>
|
||||
<div className={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}>
|
||||
{([
|
||||
{
|
||||
key: "channels",
|
||||
@@ -302,7 +293,7 @@ export default function SettingsPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 text-xs text-foreground/65">
|
||||
<Info className="size-3.5 shrink-0" />
|
||||
<span>渠道保存连接和默认协议;拉取模型后,请为需要特殊路径的模型单独选择请求协议。</span>
|
||||
<span>渠道只保存连接类型;拉取模型后,请在每个模型下配置能力与请求协议。</span>
|
||||
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>
|
||||
打开模型选择
|
||||
</Button>
|
||||
@@ -324,10 +315,10 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{userChannels.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{userChannels.map((channel) => (
|
||||
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-3">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-2.5 sm:p-3">
|
||||
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
|
||||
<div className="min-w-0 flex-1 basis-52">
|
||||
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">
|
||||
{channel.name || "未命名渠道"}
|
||||
@@ -384,12 +375,12 @@ export default function SettingsPage() {
|
||||
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="默认模型协议" htmlFor={`channel-${channel.id}-protocol`} className="mb-0 lg:col-span-3" extra="用于新模型预填,单个模型可在下方覆盖。">
|
||||
<Select<UserChannelProtocol>
|
||||
id={`channel-${channel.id}-protocol`}
|
||||
value={channelProtocolValue(channel)}
|
||||
options={channelProtocolOptions}
|
||||
onChange={(value) => updateChannelProtocol(channel, value)}
|
||||
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。">
|
||||
<Segmented<UserChannelConnection>
|
||||
block
|
||||
value={channelConnectionMode(channel)}
|
||||
options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]}
|
||||
onChange={(value) => updateChannelConnection(channel, value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
|
||||
@@ -412,6 +403,16 @@ export default function SettingsPage() {
|
||||
onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。">
|
||||
<Input.Password
|
||||
id={`channel-${channel.id}-secret-key`}
|
||||
autoComplete="new-password"
|
||||
value={channel.secretKey || ""}
|
||||
placeholder="填写 Secret Key"
|
||||
onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })}
|
||||
onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
|
||||
<Select
|
||||
id={`channel-${channel.id}-models`}
|
||||
@@ -593,9 +594,8 @@ function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function channelProtocolValue(channel: ModelChannel): UserChannelProtocol {
|
||||
if (channel.apiFormat === "gemini") return "gemini";
|
||||
return channel.interfaceType || "auto";
|
||||
function channelConnectionMode(channel: ModelChannel): UserChannelConnection {
|
||||
return channel.apiFormat === "gemini" ? "gemini" : "openai";
|
||||
}
|
||||
|
||||
function channelConnectionError(channel: ModelChannel) {
|
||||
@@ -607,12 +607,13 @@ function channelConnectionError(channel: ModelChannel) {
|
||||
} catch {
|
||||
return "Base URL 格式不正确";
|
||||
}
|
||||
if (!channel.apiKey.trim()) return "请填写 API Key";
|
||||
if (!channel.apiKey.trim()) return "请填写 API Key / Access Key";
|
||||
if (requiresSecretKey(channel) && !channel.secretKey?.trim()) return "当前协议需要填写 Secret Key";
|
||||
return "";
|
||||
}
|
||||
|
||||
function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
function channelValidationError(channel: ModelChannel) {
|
||||
@@ -624,8 +625,8 @@ function isChannelReady(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function focusInvalidChannelField(channel: ModelChannel) {
|
||||
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid" });
|
||||
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : "models";
|
||||
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid", secretKey: "valid" });
|
||||
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : requiresSecretKey(channel) && !channel.secretKey?.trim() ? "secret-key" : "models";
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(`channel-${channel.id}-${field}`);
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -634,19 +635,7 @@ function focusInvalidChannelField(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function channelProtocolLabel(channel: ModelChannel) {
|
||||
const protocol = channelProtocolValue(channel);
|
||||
switch (protocol) {
|
||||
case "gemini":
|
||||
return "Gemini 原生";
|
||||
case "chat-completion":
|
||||
return "Chat Completions";
|
||||
case "openai-response":
|
||||
return "OpenAI Responses";
|
||||
case "openai-image":
|
||||
return "OpenAI Images";
|
||||
default:
|
||||
return protocol === "auto" ? "OpenAI 自动兼容" : modelProtocolLabel(protocol);
|
||||
}
|
||||
return channelConnectionMode(channel) === "gemini" ? "Gemini 原生" : "OpenAI 兼容";
|
||||
}
|
||||
|
||||
function isKnownDefaultBaseUrl(value: string) {
|
||||
@@ -654,3 +643,7 @@ function isKnownDefaultBaseUrl(value: string) {
|
||||
if (!normalized) return true;
|
||||
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
|
||||
}
|
||||
|
||||
function requiresSecretKey(channel: ModelChannel) {
|
||||
return channel.interfaceType?.startsWith("volcengine-jimeng-") === true || channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/componen
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
|
||||
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
|
||||
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
@@ -676,6 +677,7 @@ function formatTaskJson(value?: string) {
|
||||
|
||||
function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
|
||||
return {
|
||||
channelId: config.channelId,
|
||||
apiFormat: config.apiFormat,
|
||||
interfaceType: config.interfaceType,
|
||||
baseUrl: config.baseUrl,
|
||||
@@ -694,6 +696,7 @@ function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConf
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
audioInstructions: config.audioInstructions,
|
||||
capabilityConfig: modelCapabilityConfigFor(config, config.model),
|
||||
systemPrompt: config.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { getMediaBlob } from "@/services/file-storage";
|
||||
import { getImageBlob } from "@/services/image-storage";
|
||||
import { resourceIdFromStorageKey, resourceStorageKey, uploadResourceFile } from "@/services/api/resources";
|
||||
import { createGenerationTask, waitForGenerationTask, type GenerationTask } from "@/services/api/task-center";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||
@@ -144,6 +145,9 @@ function backendImageReference(image: ReferenceImage, override: Partial<Referenc
|
||||
dataUrl: "",
|
||||
url: override.url,
|
||||
storageKey: override.storageKey,
|
||||
...(image.bytes ? { bytes: image.bytes } : {}),
|
||||
...(image.width ? { width: image.width } : {}),
|
||||
...(image.height ? { height: image.height } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,6 +187,7 @@ export function backendProviderConfig(config: AiConfig) {
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
audioInstructions: config.audioInstructions,
|
||||
capabilityConfig: modelCapabilityConfigFor(config, requestConfig.model),
|
||||
systemPrompt: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/fil
|
||||
import { getResourceOSSUrl } from "@/services/api/resources";
|
||||
import { channelRequest } from "@/services/api/custom-channel-relay";
|
||||
import { imageToDataUrl } from "@/services/image-storage";
|
||||
import { modelCapabilityConfigFor, videoDurationAllowed } from "@/lib/model-capabilities";
|
||||
import { boolConfig, buildSeedancePromptText, isArkPlanBaseUrl, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||
import { buildApiUrl, isSystemProxyBaseUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import type { ReferenceImage } from "@/types/image";
|
||||
@@ -60,6 +61,7 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
|
||||
const selectedModel = (config.model || config.videoModel).trim();
|
||||
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
||||
assertVideoConfig(requestConfig, requestConfig.model);
|
||||
assertVideoCapability(modelCapabilityConfigFor(config, selectedModel).video!, prompt, references, videoReferences, audioReferences, config.videoSeconds);
|
||||
if (requestConfig.interfaceType === "newapi-channel-2") {
|
||||
return createVideoGenerationsTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||
}
|
||||
@@ -78,6 +80,21 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
|
||||
return createOpenAIVideoTask(requestConfig, selectedModel, prompt, references, options);
|
||||
}
|
||||
|
||||
function assertVideoCapability(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>, prompt: string, references: ReferenceImage[], videoReferences: ReferenceVideo[], audioReferences: ReferenceAudio[], seconds: string) {
|
||||
if (Array.from(prompt).length > profile.references.promptMaxChars) throw new Error(`提示词超过当前模型限制(最多 ${profile.references.promptMaxChars} 字)`);
|
||||
if (references.length > profile.references.maxImages || videoReferences.length > profile.references.maxVideos || audioReferences.length > profile.references.maxAudios) throw new Error("参考素材数量超过当前模型限制");
|
||||
if (!videoDurationAllowed(profile, Number(seconds))) throw new Error("视频时长不在当前模型支持范围内");
|
||||
if (profile.references.maxImageBytes > 0 && references.some((image) => (image.bytes || 0) > profile.references.maxImageBytes)) throw new Error("参考图片文件超过当前模型大小限制");
|
||||
for (const video of videoReferences) {
|
||||
if (profile.references.maxVideoBytes > 0 && (video.bytes || 0) > profile.references.maxVideoBytes) throw new Error("参考视频文件超过当前模型大小限制");
|
||||
if (profile.references.maxVideoDurationSeconds > 0 && (video.durationMs || 0) > profile.references.maxVideoDurationSeconds * 1000) throw new Error("参考视频时长超过当前模型限制");
|
||||
}
|
||||
for (const audio of audioReferences) {
|
||||
if (profile.references.maxAudioBytes > 0 && (audio.bytes || 0) > profile.references.maxAudioBytes) throw new Error("参考音频文件超过当前模型大小限制");
|
||||
if (profile.references.maxAudioDurationSeconds > 0 && (audio.durationMs || 0) > profile.references.maxAudioDurationSeconds * 1000) throw new Error("参考音频时长超过当前模型限制");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||
const requestConfig = resolveModelRequestConfig(config, task.model);
|
||||
assertVideoConfig(requestConfig, requestConfig.model);
|
||||
@@ -94,13 +111,14 @@ async function createVideoGenerationsTask(config: ResolvedAiConfig, model: strin
|
||||
Promise.all(videoReferences.map((item) => resolveVideoGenerationsUrl(item.url, item.storageKey))),
|
||||
Promise.all(audioReferences.map((item) => resolveVideoGenerationsUrl(item.url, item.storageKey))),
|
||||
]);
|
||||
const profile = modelCapabilityConfigFor(config, model).video!;
|
||||
const payload = {
|
||||
model: modelOptionName(model),
|
||||
prompt: prompt.trim(),
|
||||
seconds: normalizeVideoSeconds(config.videoSeconds),
|
||||
aspect_ratio: normalizeVideoSize(config.size) || "16:9",
|
||||
resolution: normalizeVideoResolution(config.vquality),
|
||||
generate_audio: boolConfig(config.videoGenerateAudio, true),
|
||||
...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}),
|
||||
...(imageUrls.length ? { image_urls: imageUrls } : {}),
|
||||
...(videoUrls.length ? { video_urls: videoUrls } : {}),
|
||||
...(audioUrls.length ? { audio_urls: audioUrls } : {}),
|
||||
@@ -385,14 +403,15 @@ async function buildSeedanceAgentPlanPayload(config: ResolvedAiConfig, model: st
|
||||
}
|
||||
const content = config.interfaceType === "volcengine-ark-video" ? await buildVolcengineArkContent(prompt, references, videoReferences, audioReferences) : await buildSeedanceContent(config, prompt, references, videoReferences, audioReferences);
|
||||
if (!content.length) throw new Error("请输入视频提示词,或连接参考图片/视频/音频");
|
||||
const profile = modelCapabilityConfigFor(config, model).video!;
|
||||
return {
|
||||
model: modelOptionName(model),
|
||||
content,
|
||||
ratio: normalizeSeedanceRatio(config.size),
|
||||
resolution: normalizeSeedanceResolution(config.vquality, modelOptionName(model)),
|
||||
duration: normalizeSeedanceDuration(config.videoSeconds),
|
||||
generate_audio: boolConfig(config.videoGenerateAudio, true),
|
||||
watermark: boolConfig(config.videoWatermark, false),
|
||||
...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}),
|
||||
...(profile.watermark.supported ? { watermark: boolConfig(config.videoWatermark, profile.watermark.default) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -426,12 +445,13 @@ async function buildSeedanceVideosPayload(config: AiConfig, model: string, promp
|
||||
const audioUrls = await Promise.all(audioReferences.slice(0, SEEDANCE_REFERENCE_LIMITS.audios).map(resolveSeedanceVideosMediaUrl));
|
||||
const ratio = normalizeSeedanceRatio(config.size);
|
||||
const duration = normalizeSeedanceDuration(config.videoSeconds);
|
||||
const profile = modelCapabilityConfigFor(config, model).video!;
|
||||
return {
|
||||
model: modelOptionName(model),
|
||||
prompt: buildSeedanceVideosPromptText(prompt, imageUrls.length, videoUrls.length, audioUrls.length),
|
||||
aspect_ratio: ratio === "adaptive" ? "16:9" : ratio,
|
||||
duration,
|
||||
generate_audio: boolConfig(config.videoGenerateAudio, true),
|
||||
...(profile.generateAudio.supported ? { generate_audio: boolConfig(config.videoGenerateAudio, profile.generateAudio.default) } : {}),
|
||||
...(imageUrls[0] ? { image_url: imageUrls[0] } : {}),
|
||||
...(imageUrls.length > 1 ? { reference_image_urls: imageUrls.slice(1) } : {}),
|
||||
...(videoUrls.length ? { reference_videos: videoUrls } : {}),
|
||||
|
||||
@@ -74,6 +74,8 @@ export type ChannelModel = {
|
||||
priceConfigured: boolean;
|
||||
enabled: boolean;
|
||||
priceVersion: number;
|
||||
capabilityVersion?: number;
|
||||
capabilityConfig?: import("@/lib/model-capabilities").ModelCapabilityConfig;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -241,7 +243,7 @@ export function fetchAdminChannelModels(channelId: string) {
|
||||
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
|
||||
}
|
||||
|
||||
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "capability" | "protocol">) {
|
||||
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "capability" | "protocol"> & { capabilityConfig?: ChannelModel["capabilityConfig"] }) {
|
||||
return request<{ durationMs: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/test`, input, { timeout: 10 * 60 * 1000 }));
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nanoid } from "nanoid";
|
||||
import { scopedLocalStorage } from "@/lib/user-scope";
|
||||
import { modelProtocolCapability, normalizeModelProtocol, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import type { ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
|
||||
export type ApiCallFormat = "openai" | "gemini";
|
||||
export type ChannelInterfaceType = ModelProtocol;
|
||||
@@ -36,6 +37,7 @@ export type ModelChannel = {
|
||||
inputTokenPriceMicrocredits?: number;
|
||||
outputTokenPriceMicrocredits?: number;
|
||||
cachedTokenPriceMicrocredits?: number;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
}>;
|
||||
};
|
||||
|
||||
|
||||
@@ -303,16 +303,20 @@
|
||||
--motion-panel: 240ms;
|
||||
--ease-aceternity: var(--motion-ease-out);
|
||||
--ease-product-enter: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--workspace-canvas: #ffffff;
|
||||
--workspace-surface: rgba(255, 255, 255, 0.82);
|
||||
--workspace-canvas: var(--background);
|
||||
/* 页面、导航和内容区共用同一底色;卡片/浮层才允许使用 surface token。 */
|
||||
--workspace-page: var(--workspace-canvas);
|
||||
--workspace-navigation: var(--workspace-page);
|
||||
--workspace-content: var(--workspace-page);
|
||||
--workspace-surface: #ffffff;
|
||||
--workspace-surface-strong: #ffffff;
|
||||
--workspace-border: rgba(15, 23, 42, 0.14);
|
||||
--workspace-border-strong: rgba(15, 23, 42, 0.18);
|
||||
--workspace-grid-line: rgba(51, 65, 85, 0.045);
|
||||
--workspace-nav: rgba(255, 255, 255, 0.88);
|
||||
--workspace-sidebar: #f5f6f8;
|
||||
--workspace-accent: #2563eb;
|
||||
--workspace-accent-soft: #eaf1ff;
|
||||
--workspace-border: rgba(17, 17, 17, 0.14);
|
||||
--workspace-border-strong: rgba(17, 17, 17, 0.22);
|
||||
--workspace-grid-line: rgba(17, 17, 17, 0.045);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #171717;
|
||||
--workspace-accent-soft: rgba(17, 17, 17, 0.07);
|
||||
|
||||
/* --- Semantic 层(引用 Primitive,随主题切换)--- */
|
||||
/* 通用语义色(引用 Primitive neutral 色板)*/
|
||||
@@ -423,16 +427,19 @@
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--workspace-canvas: #0b0c0d;
|
||||
--workspace-surface: rgba(20, 21, 22, 0.72);
|
||||
--workspace-surface-strong: rgba(25, 26, 27, 0.9);
|
||||
--workspace-canvas: var(--background);
|
||||
--workspace-page: var(--workspace-canvas);
|
||||
--workspace-navigation: var(--workspace-page);
|
||||
--workspace-content: var(--workspace-page);
|
||||
--workspace-surface: #181818;
|
||||
--workspace-surface-strong: #181818;
|
||||
--workspace-border: rgba(255, 255, 255, 0.085);
|
||||
--workspace-border-strong: rgba(255, 255, 255, 0.16);
|
||||
--workspace-grid-line: rgba(255, 255, 255, 0.038);
|
||||
--workspace-nav: rgba(11, 12, 13, 0.82);
|
||||
--workspace-sidebar: #101112;
|
||||
--workspace-accent: #7da2ff;
|
||||
--workspace-accent-soft: rgba(80, 125, 230, 0.16);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #f5f5f5;
|
||||
--workspace-accent-soft: rgba(255, 255, 255, 0.09);
|
||||
--chart-1: oklch(0.87 0 0);
|
||||
--chart-2: oklch(0.556 0 0);
|
||||
--chart-3: oklch(0.439 0 0);
|
||||
@@ -569,6 +576,7 @@
|
||||
/* Prompt 面板(对应 #98 决策2 渐进式三区)*/
|
||||
--prompt-panel-max-height: min(70vh, 640px);
|
||||
--prompt-panel-input-min-height: 72px;
|
||||
--prompt-panel-input-max-height: 224px;
|
||||
--prompt-panel-section-fs: var(--fs-body); /* 14px A区 */
|
||||
--prompt-panel-caption-fs: var(--fs-caption); /* 12px B区 */
|
||||
--prompt-panel-label-fs: var(--fs-label); /* 11px 标签 */
|
||||
@@ -610,10 +618,11 @@
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
overscroll-behavior: none;
|
||||
background: var(--background);
|
||||
background: var(--workspace-page);
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
background: var(--workspace-page);
|
||||
color: var(--foreground);
|
||||
}
|
||||
#root {
|
||||
position: fixed;
|
||||
@@ -686,14 +695,17 @@
|
||||
|
||||
@layer utilities {
|
||||
.app-spatial-workspace {
|
||||
--workspace-canvas: #f5f5f5;
|
||||
--workspace-canvas: var(--background);
|
||||
--workspace-page: var(--workspace-canvas);
|
||||
--workspace-navigation: var(--workspace-page);
|
||||
--workspace-content: var(--workspace-page);
|
||||
--workspace-surface: rgba(255, 255, 255, 0.74);
|
||||
--workspace-surface-strong: rgba(255, 255, 255, 0.94);
|
||||
--workspace-border: rgba(17, 17, 17, 0.1);
|
||||
--workspace-border-strong: rgba(17, 17, 17, 0.2);
|
||||
--workspace-grid-line: rgba(17, 17, 17, 0.035);
|
||||
--workspace-nav: rgba(250, 250, 250, 0.86);
|
||||
--workspace-sidebar: rgba(250, 250, 250, 0.72);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #171717;
|
||||
--workspace-accent-soft: rgba(17, 17, 17, 0.065);
|
||||
background-color: var(--workspace-canvas);
|
||||
@@ -704,22 +716,25 @@
|
||||
}
|
||||
|
||||
.dark .app-spatial-workspace {
|
||||
--workspace-canvas: #080808;
|
||||
--workspace-canvas: var(--background);
|
||||
--workspace-page: var(--workspace-canvas);
|
||||
--workspace-navigation: var(--workspace-page);
|
||||
--workspace-content: var(--workspace-page);
|
||||
--workspace-surface: rgba(18, 18, 18, 0.76);
|
||||
--workspace-surface-strong: rgba(22, 22, 22, 0.94);
|
||||
--workspace-border: rgba(255, 255, 255, 0.09);
|
||||
--workspace-border-strong: rgba(255, 255, 255, 0.18);
|
||||
--workspace-grid-line: rgba(255, 255, 255, 0.026);
|
||||
--workspace-nav: rgba(10, 10, 10, 0.88);
|
||||
--workspace-sidebar: rgba(12, 12, 13, 0.72);
|
||||
--workspace-nav: var(--workspace-navigation);
|
||||
--workspace-sidebar: var(--workspace-navigation);
|
||||
--workspace-accent: #f5f5f5;
|
||||
--workspace-accent-soft: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial {
|
||||
position: relative;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.app-workspace-sidebar-scrim {
|
||||
@@ -732,24 +747,17 @@
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
border: 1px solid color-mix(in srgb, var(--foreground) 16%, transparent);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
linear-gradient(145deg, color-mix(in srgb, white 10%, transparent), transparent 24%, transparent 76%, color-mix(in srgb, var(--foreground) 3%, transparent)),
|
||||
var(--workspace-sidebar);
|
||||
box-shadow:
|
||||
0 26px 70px rgba(0, 0, 0, 0.12),
|
||||
inset 0 1px 0 color-mix(in srgb, white 24%, transparent),
|
||||
inset 1px 0 0 color-mix(in srgb, white 7%, transparent);
|
||||
backdrop-filter: blur(28px) saturate(132%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(132%);
|
||||
border: 0;
|
||||
border-right: 1px solid var(--workspace-border);
|
||||
border-radius: 0;
|
||||
background: var(--workspace-navigation);
|
||||
box-shadow: none;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
.dark .app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
box-shadow:
|
||||
0 28px 80px rgba(0, 0, 0, 0.46),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.18),
|
||||
inset 1px 0 0 rgba(255, 255, 255, 0.055);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays {
|
||||
@@ -902,8 +910,8 @@
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-stage {
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--workspace-canvas) 92%, transparent);
|
||||
border-radius: 0;
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
/* 工作区保留滚轮与触控板滚动,只隐藏会形成额外竖向分割的浏览器轨道。 */
|
||||
@@ -1159,15 +1167,33 @@
|
||||
}
|
||||
|
||||
.app-user-workspace {
|
||||
background-color: var(--workspace-canvas);
|
||||
background-color: var(--workspace-page);
|
||||
}
|
||||
|
||||
.app-workspace-shell {
|
||||
background: var(--workspace-page);
|
||||
}
|
||||
|
||||
.app-workspace-sidebar {
|
||||
border-right: 1px solid var(--workspace-border);
|
||||
background: var(--workspace-sidebar);
|
||||
background: var(--workspace-navigation);
|
||||
transition: width var(--motion-state) var(--ease-aceternity);
|
||||
}
|
||||
|
||||
.app-workspace-stage,
|
||||
.app-user-content,
|
||||
.app-workspace-grid {
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
.app-workspace-page {
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
.app-workspace-navigation {
|
||||
background: var(--workspace-navigation);
|
||||
}
|
||||
|
||||
.app-workspace-nav-link.is-active {
|
||||
background: color-mix(in srgb, var(--foreground) 6%, transparent);
|
||||
color: var(--foreground);
|
||||
@@ -1260,12 +1286,12 @@
|
||||
|
||||
.app-workspace-sidebar-scroll-area.has-top-fade::before {
|
||||
top: 0;
|
||||
background: linear-gradient(to bottom, var(--workspace-surface-strong) 0%, transparent 100%);
|
||||
background: var(--workspace-navigation);
|
||||
}
|
||||
|
||||
.app-workspace-sidebar-scroll-area.has-bottom-fade::after {
|
||||
bottom: 0;
|
||||
background: linear-gradient(to top, var(--workspace-surface-strong) 0%, transparent 100%);
|
||||
background: var(--workspace-navigation);
|
||||
}
|
||||
|
||||
.workspace-signal-icon {
|
||||
@@ -1299,14 +1325,9 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.app-user-content,
|
||||
.app-workspace-grid {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.creation-home {
|
||||
--creation-accent: #04a9cd;
|
||||
--creation-bg: rgba(246, 248, 251, .78);
|
||||
--creation-accent: var(--workspace-accent);
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: rgba(255, 255, 255, .68);
|
||||
--creation-surface-muted: rgba(238, 241, 245, .68);
|
||||
--creation-surface-hover: rgba(228, 232, 237, .7);
|
||||
@@ -1324,8 +1345,8 @@
|
||||
}
|
||||
|
||||
.dark .creation-home {
|
||||
--creation-accent: #06b6d4;
|
||||
--creation-bg: rgba(10, 11, 13, .86);
|
||||
--creation-accent: var(--workspace-accent);
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: rgba(27, 29, 34, .72);
|
||||
--creation-surface-muted: rgba(35, 38, 44, .68);
|
||||
--creation-surface-hover: rgba(47, 50, 58, .72);
|
||||
@@ -2301,7 +2322,7 @@
|
||||
|
||||
/* 创作首页使用独立的“编辑面 + 操作 Dock”结构,避免继承画布的面板语言。 */
|
||||
.creation-home {
|
||||
--creation-bg: #0d0e10;
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: #131519;
|
||||
--creation-surface-muted: #1e2127;
|
||||
--creation-surface-hover: #262a31;
|
||||
@@ -2317,7 +2338,7 @@
|
||||
}
|
||||
|
||||
html:not(.dark) .creation-home {
|
||||
--creation-bg: #f8f9fb;
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: #ffffff;
|
||||
--creation-surface-muted: #f0f2f5;
|
||||
--creation-surface-hover: #e9edf1;
|
||||
@@ -3735,11 +3756,11 @@
|
||||
}
|
||||
|
||||
.app-workspace-canvas {
|
||||
background: transparent;
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
.dark .app-workspace-canvas {
|
||||
background: transparent;
|
||||
background: var(--workspace-content);
|
||||
}
|
||||
|
||||
.app-spatial-surface {
|
||||
@@ -4837,8 +4858,8 @@ html[data-theme-switching="true"] :where(.canvas-node-shell, .aceternity-comet-c
|
||||
|
||||
/* 创作页是工作舞台,不使用管理台的边框、卡片和分段控件语言。 */
|
||||
.creation-home {
|
||||
--creation-accent: #dce4e9;
|
||||
--creation-bg: #0d0e10;
|
||||
--creation-accent: var(--workspace-accent);
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: #191b20;
|
||||
--creation-surface-raised: #22252b;
|
||||
--creation-text: #f2f3f4;
|
||||
@@ -4872,7 +4893,7 @@ html:not(.dark) .creation-scrollbar::-webkit-scrollbar-thumb { background-color:
|
||||
html:not(.dark) .creation-scrollbar::-webkit-scrollbar-thumb:hover { background-color: rgba(35, 41, 49, .38); }
|
||||
|
||||
html:not(.dark) .creation-home {
|
||||
--creation-bg: #f7f7f5;
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: #ffffff;
|
||||
--creation-surface-raised: #f0f1ee;
|
||||
--creation-text: #1d2024;
|
||||
@@ -5534,13 +5555,13 @@ html:not(.dark) .creation-model-picker-menu .canvas-model-picker-option > span:f
|
||||
.app-workspace-shell.is-creation-workspace,
|
||||
.app-workspace-shell.is-creation-workspace .app-workspace-sidebar,
|
||||
.app-workspace-shell.is-creation-workspace .app-workspace-stage {
|
||||
background: #0d0e10 !important;
|
||||
background: var(--workspace-page) !important;
|
||||
}
|
||||
|
||||
html:not(.dark) .app-workspace-shell.is-creation-workspace,
|
||||
html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-sidebar,
|
||||
html:not(.dark) .app-workspace-shell.is-creation-workspace .app-workspace-stage {
|
||||
background: #f7f7f5 !important;
|
||||
background: var(--workspace-page) !important;
|
||||
}
|
||||
|
||||
.creation-empty-workspace {
|
||||
@@ -6297,3 +6318,81 @@ html:not(.dark) .creation-history-drawer-root {
|
||||
.creation-history-drawer-root .ant-drawer-content-wrapper { width: 100vw !important; max-width: 100vw; }
|
||||
.creation-history-timeline { padding-right: 12px; padding-left: 18px; }
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
Monochrome product surfaces
|
||||
页面级黑白主题收口:焦点用边框表达,浮层使用单一表面,避免渐变、米色和蓝紫光圈在不同页面漂移。
|
||||
------------------------------------------------------------------------- */
|
||||
.creation-home {
|
||||
--creation-accent: var(--workspace-accent);
|
||||
--creation-bg: var(--workspace-page);
|
||||
--creation-surface: color-mix(in srgb, var(--background) 96%, var(--foreground) 4%);
|
||||
--creation-surface-muted: color-mix(in srgb, var(--background) 91%, var(--foreground) 9%);
|
||||
--creation-surface-hover: color-mix(in srgb, var(--background) 86%, var(--foreground) 14%);
|
||||
--creation-surface-selected: color-mix(in srgb, var(--background) 80%, var(--foreground) 20%);
|
||||
--creation-border: color-mix(in srgb, var(--foreground) 14%, transparent);
|
||||
--creation-border-strong: color-mix(in srgb, var(--foreground) 24%, transparent);
|
||||
}
|
||||
|
||||
:where(.ant-input, .ant-input-affix-wrapper, .ant-input-number, .ant-select-selector, .ant-select-multiple .ant-select-selection-search-input) {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
:where(.ant-input:focus, .ant-input-focused, .ant-input-affix-wrapper-focused, .ant-input-number-focused, .ant-select-focused .ant-select-selector) {
|
||||
border-color: var(--foreground) !important;
|
||||
box-shadow: none !important;
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
:where(.ant-input:hover, .ant-input-affix-wrapper:hover, .ant-input-number:hover, .ant-select:not(.ant-select-disabled):hover .ant-select-selector) {
|
||||
border-color: color-mix(in srgb, var(--foreground) 42%, var(--border)) !important;
|
||||
}
|
||||
|
||||
:where(.ant-select-dropdown, .ant-dropdown-menu, .ant-popover-inner, .ant-picker-dropdown .ant-picker-panel-container) {
|
||||
border: 1px solid var(--border) !important;
|
||||
border-radius: var(--r-md) !important;
|
||||
background: var(--popover) !important;
|
||||
background-image: none !important;
|
||||
box-shadow: var(--shadow-lg) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
:where(.ant-select-item-option, .ant-dropdown-menu-item, .ant-cascader-menu-item) {
|
||||
border-radius: var(--r-sm) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
}
|
||||
|
||||
:where(.ant-select-item-option-active:not(.ant-select-item-option-disabled), .ant-dropdown-menu-item:hover, .ant-cascader-menu-item-active) {
|
||||
background: color-mix(in srgb, var(--popover-foreground) 6%, var(--popover)) !important;
|
||||
}
|
||||
|
||||
:where(.ant-select-item-option-selected:not(.ant-select-item-option-disabled), .ant-dropdown-menu-item-selected) {
|
||||
background: color-mix(in srgb, var(--popover-foreground) 12%, var(--popover)) !important;
|
||||
color: var(--popover-foreground) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:where(.ant-tag-success, .ant-tag-warning, .ant-tag-processing, .ant-tag-cyan, .ant-tag-blue, .ant-tag-green, .ant-tag-gold, .ant-tag-orange) {
|
||||
border-color: color-mix(in srgb, var(--foreground) 18%, transparent) !important;
|
||||
background: color-mix(in srgb, var(--foreground) 7%, transparent) !important;
|
||||
color: color-mix(in srgb, var(--foreground) 76%, transparent) !important;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays :where(.ant-dropdown-menu, .ant-select-dropdown, .ant-popover-inner, .ant-modal-content, .ant-drawer-content, .ant-message-notice-content, .ant-notification-notice) {
|
||||
background: var(--popover) !important;
|
||||
background-image: none !important;
|
||||
box-shadow: var(--shadow-xl) !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
|
||||
background: color-mix(in srgb, var(--foreground) 48%, transparent) !important;
|
||||
backdrop-filter: blur(3px) !important;
|
||||
}
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-sidebar {
|
||||
background: var(--workspace-navigation) !important;
|
||||
background-image: none !important;
|
||||
box-shadow: none !important;
|
||||
backdrop-filter: none !important;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ export type ReferenceImage = {
|
||||
dataUrl: string;
|
||||
url?: string;
|
||||
storageKey?: string;
|
||||
bytes?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
source?: {
|
||||
kind: "drawing";
|
||||
drawingId: string;
|
||||
|
||||
Reference in New Issue
Block a user