refactor(core): 模型路由与画布 - 收敛渠道模型映射并优化交互
This commit is contained in:
@@ -22,7 +22,6 @@ type CanvasNodeProps = {
|
||||
isRelated: boolean;
|
||||
isFocusRelated: boolean;
|
||||
isConnectionTarget: boolean;
|
||||
isConnecting: boolean;
|
||||
forceInputVisible?: boolean;
|
||||
showImageInfo: boolean;
|
||||
reduceMediaEffects?: boolean;
|
||||
@@ -364,9 +363,9 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{hasReplaceAction ? (
|
||||
{hasReplaceAction && (!hasMediaContent || isSelected || hovered) ? (
|
||||
<div
|
||||
className={`absolute right-3 top-3 z-[var(--node-z-overlay)] motion-safe:transition motion-safe:duration-200 ${!hasMediaContent || isSelected || hovered ? "opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
className="absolute right-3 top-3 z-[var(--node-z-overlay)]"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
@@ -436,7 +435,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!readOnly && !data.metadata?.locked ? <>
|
||||
{!readOnly && !data.metadata?.locked && (isSelected || hovered) ? <>
|
||||
<ResizeHandle corner="top-left" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="top-right" onMouseDown={handleResizeMouseDown} />
|
||||
<ResizeHandle corner="bottom-left" onMouseDown={handleResizeMouseDown} />
|
||||
@@ -444,12 +443,61 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
</> : null}
|
||||
</div>
|
||||
|
||||
{!readOnly && data.type !== CanvasNodeType.Script ? <ConnectionSideRail side="left" scale={scale} visible={hovered || forceInputVisible} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config ? <ConnectionSideRail side="right" scale={scale} visible={hovered} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && (hovered || forceInputVisible) ? <ConnectionSideRail side="left" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "target", undefined, anchorRatio)} /> : null}
|
||||
{!readOnly && data.type !== CanvasNodeType.Script && data.type !== CanvasNodeType.Config && hovered ? <ConnectionSideRail side="right" scale={scale} theme={theme} onPointerDown={(event, anchorRatio) => onConnectStart(event, data.id, "source", undefined, anchorRatio)} /> : null}
|
||||
|
||||
</div>
|
||||
);
|
||||
});
|
||||
}, areCanvasNodePropsEqual);
|
||||
|
||||
function areCanvasNodePropsEqual(previous: CanvasNodeProps, next: CanvasNodeProps) {
|
||||
return (
|
||||
previous.data === next.data &&
|
||||
previous.dragOffset?.x === next.dragOffset?.x &&
|
||||
previous.dragOffset?.y === next.dragOffset?.y &&
|
||||
previous.scale === next.scale &&
|
||||
previous.isSelected === next.isSelected &&
|
||||
previous.isRelated === next.isRelated &&
|
||||
previous.isFocusRelated === next.isFocusRelated &&
|
||||
previous.isConnectionTarget === next.isConnectionTarget &&
|
||||
previous.forceInputVisible === next.forceInputVisible &&
|
||||
previous.showImageInfo === next.showImageInfo &&
|
||||
previous.reduceMediaEffects === next.reduceMediaEffects &&
|
||||
previous.readOnly === next.readOnly &&
|
||||
previous.resourceLabel === next.resourceLabel &&
|
||||
previous.mentionReferences === next.mentionReferences &&
|
||||
previous.renderNodeContent === next.renderNodeContent &&
|
||||
previous.drawingProjectId === next.drawingProjectId &&
|
||||
previous.batchCount === next.batchCount &&
|
||||
previous.batchExpanded === next.batchExpanded &&
|
||||
previous.batchClosing === next.batchClosing &&
|
||||
previous.batchOpening === next.batchOpening &&
|
||||
previous.batchRecovering === next.batchRecovering &&
|
||||
previous.batchPrimary === next.batchPrimary &&
|
||||
previous.batchMotion?.x === next.batchMotion?.x &&
|
||||
previous.batchMotion?.y === next.batchMotion?.y &&
|
||||
previous.batchMotion?.index === next.batchMotion?.index &&
|
||||
previous.onMouseDown === next.onMouseDown &&
|
||||
previous.onHoverStart === next.onHoverStart &&
|
||||
previous.onHoverEnd === next.onHoverEnd &&
|
||||
previous.onConnectStart === next.onConnectStart &&
|
||||
previous.onResize === next.onResize &&
|
||||
previous.onTitleChange === next.onTitleChange &&
|
||||
previous.onContentChange === next.onContentChange &&
|
||||
previous.onToggleBatch === next.onToggleBatch &&
|
||||
previous.onSetBatchPrimary === next.onSetBatchPrimary &&
|
||||
previous.onRetry === next.onRetry &&
|
||||
previous.onCancelTask === next.onCancelTask &&
|
||||
previous.onOpenTaskDetails === next.onOpenTaskDetails &&
|
||||
previous.onOpenVersions === next.onOpenVersions &&
|
||||
previous.onViewImage === next.onViewImage &&
|
||||
previous.onReplaceMedia === next.onReplaceMedia &&
|
||||
previous.onOpenTextEditor === next.onOpenTextEditor &&
|
||||
previous.onOpenDirector === next.onOpenDirector &&
|
||||
previous.onOpenDrawing === next.onOpenDrawing &&
|
||||
previous.onContextMenu === next.onContextMenu
|
||||
);
|
||||
}
|
||||
|
||||
function ResourceLabelBadge({ reference, theme }: { reference: CanvasResourceReference; theme: CanvasTheme }) {
|
||||
return (
|
||||
@@ -624,7 +672,7 @@ function NodeStatusBadge({ status }: { status: "loading" | "success" | "error" }
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { side: "left" | "right"; scale: number; visible: boolean; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
|
||||
function ConnectionSideRail({ side, scale, theme, onPointerDown }: { side: "left" | "right"; scale: number; theme: CanvasTheme; onPointerDown: (event: React.PointerEvent, anchorRatio: number) => void }) {
|
||||
const handleRef = useRef<HTMLSpanElement>(null);
|
||||
const anchorRatioRef = useRef(0.5);
|
||||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||||
@@ -634,10 +682,6 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si
|
||||
if (handleRef.current) handleRef.current.style.top = "50%";
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) resetAnchor();
|
||||
}, [resetAnchor, visible]);
|
||||
|
||||
const updateAnchor = (event: React.PointerEvent<HTMLButtonElement>) => {
|
||||
const railBounds = event.currentTarget.getBoundingClientRect();
|
||||
const nodeBounds = event.currentTarget.parentElement?.getBoundingClientRect() || railBounds;
|
||||
@@ -651,7 +695,7 @@ function ConnectionSideRail({ side, scale, visible, theme, onPointerDown }: { si
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`group absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 outline-none transition-opacity duration-150 ${visible ? "pointer-events-auto opacity-100" : "pointer-events-none opacity-0"}`}
|
||||
className="group pointer-events-auto absolute top-1/2 z-[var(--node-z-overlay)] touch-none -translate-y-1/2 opacity-100 outline-none transition-opacity duration-150"
|
||||
style={{ width: 56 * inverseScale, height: `min(100%, ${72 * inverseScale}px)`, ...(side === "left" ? { right: "100%" } : { left: "100%" }) }}
|
||||
onPointerEnter={updateAnchor}
|
||||
onPointerMove={updateAnchor}
|
||||
|
||||
@@ -65,11 +65,12 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
onPointerDown={() => preloadWorkspaceRoute(tool.slug)}
|
||||
onPointerEnter={() => preloadWorkspaceRoute(tool.slug)}
|
||||
className={cn(
|
||||
"app-workspace-nav-link app-workspace-rail-tile grid size-10 place-items-center rounded-md text-[var(--fs-tiny)] transition-colors",
|
||||
"app-workspace-nav-link app-workspace-rail-tile flex min-h-12 w-full flex-col items-center justify-center gap-1 rounded-md px-1 py-1 text-[var(--fs-tiny)] leading-none transition-colors",
|
||||
active ? "is-active font-medium" : "",
|
||||
)}
|
||||
>
|
||||
<Icon className="app-workspace-nav-icon shrink-0" strokeWidth={1.8} />
|
||||
<span className="truncate">{tool.label.slice(0, 2)}</span>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -121,8 +122,9 @@ export function AppWorkspaceShell({ children }: { children: ReactNode }) {
|
||||
{/* 桌面:64px 电影胶片条式轨道 */}
|
||||
<div className="app-workspace-rail hidden min-h-0 flex-col overflow-y-auto lg:flex">
|
||||
<div className="app-workspace-rail-header flex h-16 shrink-0 items-center justify-center">
|
||||
<Link to="/home" className="grid size-10 shrink-0 place-items-center rounded-md text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
|
||||
<Link to="/home" className="app-workspace-rail-tile flex min-h-12 w-10 shrink-0 flex-col items-center justify-center gap-1 rounded-md px-1 py-1 text-[var(--fs-tiny)] leading-none text-foreground/55 transition-colors hover:bg-surface-hover hover:text-foreground" title="工作台首页">
|
||||
<Home className="app-workspace-nav-icon" strokeWidth={1.8} />
|
||||
<span>首页</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="app-workspace-rail-middle flex min-h-0 w-full flex-1 flex-col items-center justify-start overflow-y-auto px-3 pt-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Input, InputNumber, Segmented, Select, Switch } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
if (capability === "image") {
|
||||
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} />;
|
||||
}
|
||||
const profile = value?.video || defaultModelCapabilityConfig(protocol).video!;
|
||||
const profile = normalizeModelCapabilityConfig(value || 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 } });
|
||||
@@ -125,7 +125,7 @@ function TextCapabilityEditor({ value, onChange, protocol, disabled }: Pick<Prop
|
||||
}
|
||||
|
||||
function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol">) {
|
||||
const profile = value?.image || defaultImageCapabilityConfig(protocol, model);
|
||||
const profile = normalizeModelCapabilityConfig(value || { version: 1, image: defaultImageCapabilityConfig(protocol, model) }).image!;
|
||||
const update = (patch: Partial<ImageCapabilityConfig>) => onChange?.({ version: 1, image: { ...profile, ...patch } });
|
||||
const updateReferences = (patch: Partial<ImageCapabilityConfig["references"]>) => update({ references: { ...profile.references, ...patch } });
|
||||
const updateSize = (patch: Partial<ImageCapabilityConfig["size"]>) => update({ size: { ...profile.size, ...patch } });
|
||||
|
||||
@@ -89,6 +89,47 @@ export const STANDARD_IMAGE_SIZE_VALUES = [
|
||||
"1024x1536",
|
||||
] as const;
|
||||
|
||||
export function normalizeCapabilityString(value: string) {
|
||||
const normalized = value.trim();
|
||||
return normalized.startsWith("string:") ? normalized.slice("string:".length) : normalized;
|
||||
}
|
||||
|
||||
function normalizeCapabilityStrings(values: string[]) {
|
||||
return Array.from(new Set(values.map(normalizeCapabilityString)));
|
||||
}
|
||||
|
||||
export function normalizeModelCapabilityConfig(config: ModelCapabilityConfig): ModelCapabilityConfig {
|
||||
return {
|
||||
...config,
|
||||
image: config.image
|
||||
? {
|
||||
...config.image,
|
||||
size: {
|
||||
...config.image.size,
|
||||
values: normalizeCapabilityStrings(config.image.size.values),
|
||||
default: normalizeCapabilityString(config.image.size.default),
|
||||
},
|
||||
quality: {
|
||||
...config.image.quality,
|
||||
values: normalizeCapabilityStrings(config.image.quality.values),
|
||||
default: normalizeCapabilityString(config.image.quality.default),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
video: config.video
|
||||
? {
|
||||
...config.video,
|
||||
ratios: normalizeCapabilityStrings(config.video.ratios),
|
||||
defaultRatio: normalizeCapabilityString(config.video.defaultRatio),
|
||||
resolutions: normalizeCapabilityStrings(config.video.resolutions),
|
||||
defaultResolution: normalizeCapabilityString(config.video.defaultResolution),
|
||||
operations: normalizeCapabilityStrings(config.video.operations),
|
||||
defaultOperation: normalizeCapabilityString(config.video.defaultOperation),
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Keep explicit pixel presets for each resolution tier so the settings panel can
|
||||
// switch between 1K, 2K and 4K without silently converting the requested ratio.
|
||||
const defaultImageSizes = [
|
||||
@@ -271,17 +312,18 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
const cost = channel?.modelCosts?.find((item) => item.model === modelName);
|
||||
const fallback = defaultModelCapabilityConfig(cost?.protocol, modelName);
|
||||
if (!cost?.capabilityConfig) return fallback;
|
||||
const text = cost.capabilityConfig.text ? { ...fallback.text!, ...cost.capabilityConfig.text, references: { ...fallback.text!.references, ...cost.capabilityConfig.text.references } } : fallback.text;
|
||||
const video = cost.capabilityConfig.video ? { ...fallback.video!, ...cost.capabilityConfig.video, references: { ...fallback.video!.references, ...cost.capabilityConfig.video.references } } : fallback.video;
|
||||
const configuredImage = cost.capabilityConfig.image;
|
||||
const capabilityConfig = normalizeModelCapabilityConfig(cost.capabilityConfig);
|
||||
const text = capabilityConfig.text ? { ...fallback.text!, ...capabilityConfig.text, references: { ...fallback.text!.references, ...capabilityConfig.text.references } } : fallback.text;
|
||||
const video = capabilityConfig.video ? { ...fallback.video!, ...capabilityConfig.video, references: { ...fallback.video!.references, ...capabilityConfig.video.references } } : fallback.video;
|
||||
const configuredImage = capabilityConfig.image;
|
||||
const image = configuredImage
|
||||
? (() => {
|
||||
const configuredSize = configuredImage.size;
|
||||
const configuredValues = configuredSize?.values;
|
||||
const configuredValues = configuredSize?.values?.map(normalizeCapabilityString);
|
||||
const allowCustom = Boolean(configuredSize?.allowCustom || configuredValues?.includes("*"));
|
||||
const concreteValues = configuredValues?.filter((value) => value !== "*") || [];
|
||||
const values = !configuredValues ? fallback.image!.size.values : concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
|
||||
const configuredDefault = configuredSize?.default?.trim();
|
||||
const configuredDefault = configuredSize?.default ? normalizeCapabilityString(configuredSize.default) : undefined;
|
||||
const defaultValue = configuredDefault && configuredDefault !== "*" && values.includes(configuredDefault) ? configuredDefault : values.find((value) => value !== "*") || fallback.image!.size.default;
|
||||
return {
|
||||
...fallback.image!,
|
||||
@@ -296,7 +338,7 @@ export function modelCapabilityConfigFor(config: { channels: Array<{ id: string;
|
||||
};
|
||||
})()
|
||||
: fallback.image;
|
||||
return { ...fallback, ...cost.capabilityConfig, text, image, video };
|
||||
return { ...fallback, ...capabilityConfig, text, image, video };
|
||||
}
|
||||
|
||||
export function normalizeImageValue(profile: ImageCapabilityConfig, value: { size?: string; quality?: string; count?: string; transparentBackground?: string }) {
|
||||
|
||||
@@ -29,13 +29,13 @@ export function CreditOperationsPage() {
|
||||
}
|
||||
|
||||
export function AccessSettingsPage() {
|
||||
return <AdminPageFrame title="登录与注册" description="注册策略与 Linux.do"><Suspense fallback={<PageFallback label="登录与注册配置" />}><AccessSettingsPanel /></Suspense></AdminPageFrame>;
|
||||
return <AdminPageFrame title="登录与注册" description="注册策略与 Linux.do" scroll><Suspense fallback={<PageFallback label="登录与注册配置" />}><AccessSettingsPanel /></Suspense></AdminPageFrame>;
|
||||
}
|
||||
|
||||
export function EmailSettingsPage() {
|
||||
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP"><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
|
||||
return <AdminPageFrame title="邮件服务" description="注册验证码 SMTP" scroll><div className="pt-4"><Suspense fallback={<PageFallback label="邮件配置" />}><EmailSettingsPanel /></Suspense></div></AdminPageFrame>;
|
||||
}
|
||||
|
||||
export function FeatureAvailabilityPage() {
|
||||
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式"><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
|
||||
return <AdminPageFrame title="功能开放" description="控制用户工作台入口、渠道与计费模式" scroll><Suspense fallback={<PageFallback label="功能开放配置" />}><FeatureAvailabilityPanel /></Suspense></AdminPageFrame>;
|
||||
}
|
||||
|
||||
@@ -136,10 +136,10 @@ export function AdminShell() {
|
||||
);
|
||||
}
|
||||
|
||||
export function AdminPageFrame({ title, actions, back, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; children: ReactNode }) {
|
||||
export function AdminPageFrame({ title, actions, back, scroll = false, children }: { title: string; description?: string; actions?: ReactNode; back?: { label: string; onClick: () => void }; scroll?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<WorkspacePage scroll={false} fluid className="admin-page-root">
|
||||
<div className="admin-page-frame">
|
||||
<WorkspacePage scroll={scroll} fluid className={cn("admin-page-root", scroll && "admin-page-root-scrollable")}>
|
||||
<div className={cn("admin-page-frame", scroll && "admin-page-frame-scrollable")}>
|
||||
<header className="admin-page-header flex min-h-12 shrink-0 flex-col gap-2 pb-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{back ? (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
@@ -7,12 +7,10 @@ import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
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 { defaultModelCapabilityConfig, normalizeModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSupportsTokenBilling, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { createAdminPhysicalVariant, listAdminPhysicalVariants, updateAdminPhysicalVariant, type AdminPhysicalVariant } from "@/services/api/logical-models";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { CapabilitySummary, capabilitySpecFromChannelModel } from "../logical-models/model-routing-capabilities";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
import { AdminDataTable, AdminFilterChip, AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
@@ -32,11 +30,6 @@ type FormValues = {
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
};
|
||||
|
||||
type ConfigFormValues = {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||
const { message } = App.useApp();
|
||||
const [items, setItems] = useState<ChannelModel[]>([]);
|
||||
@@ -46,24 +39,16 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [configDrawerOpen, setConfigDrawerOpen] = useState(false);
|
||||
const [config, setConfig] = useState<AdminPhysicalVariant | null>(null);
|
||||
const [configLoading, setConfigLoading] = useState(false);
|
||||
const [configSaving, setConfigSaving] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [capability, setCapability] = useState<ChannelModel["capability"] | "all">("all");
|
||||
const [status, setStatus] = useState<"all" | "enabled" | "disabled">("all");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [configForm] = Form.useForm<ConfigFormValues>();
|
||||
const configRequestRef = useRef(0);
|
||||
const billingMode = Form.useWatch("billingMode", form) || "fixed_request";
|
||||
const modelCapability = Form.useWatch("capability", form);
|
||||
const modelProtocol = Form.useWatch("protocol", form);
|
||||
const modelKey = Form.useWatch("modelKey", form) || "";
|
||||
const editingCapabilitySpec = capabilitySpecFromChannelModel(editing || undefined);
|
||||
const currentConfig = editing && config?.channelModelId === editing.id ? config : null;
|
||||
|
||||
const reload = async () => {
|
||||
if (!channel) return;
|
||||
@@ -77,69 +62,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
};
|
||||
|
||||
const reloadConfig = async (channelModelID: string, defaultName = "") => {
|
||||
const requestID = ++configRequestRef.current;
|
||||
setConfigLoading(true);
|
||||
try {
|
||||
const variants = (await listAdminPhysicalVariants(channelModelID)).variants;
|
||||
if (requestID !== configRequestRef.current) return;
|
||||
const nextConfig = variants.find((item) => item.channelModelId === channelModelID) || null;
|
||||
setConfig(nextConfig);
|
||||
if (nextConfig) {
|
||||
configForm.setFieldsValue({ name: nextConfig.name, enabled: nextConfig.enabled });
|
||||
} else {
|
||||
configForm.setFieldsValue({ name: defaultName, enabled: true });
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestID === configRequestRef.current) message.error(error instanceof Error ? error.message : "读取可用配置失败");
|
||||
} finally {
|
||||
if (requestID === configRequestRef.current) setConfigLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveConfig = async () => {
|
||||
if (!editing) {
|
||||
message.info("请先保存渠道模型,再配置唯一可用配置");
|
||||
return;
|
||||
}
|
||||
const values = await configForm.validateFields();
|
||||
if (values.enabled && editing.capability !== "audio" && !editingCapabilitySpec) {
|
||||
message.error("请先完成渠道模型的供应能力配置");
|
||||
return;
|
||||
}
|
||||
if (values.enabled && !editing.enabled) {
|
||||
message.error("渠道模型已停用,不能启用可用配置");
|
||||
return;
|
||||
}
|
||||
if (config && !currentConfig) {
|
||||
message.error("可用配置与当前渠道模型不匹配,请重新打开后再试");
|
||||
return;
|
||||
}
|
||||
setConfigSaving(true);
|
||||
try {
|
||||
// 能力范围只维护在渠道模型能力参数中;可用配置只是路由开关和显示名称。
|
||||
// 后端会从渠道模型能力生成 variant 快照,避免同一尺寸/比例被填写两次。
|
||||
const payload = { channelModelId: editing.id, name: values.name.trim(), enabled: values.enabled };
|
||||
if (currentConfig) await updateAdminPhysicalVariant(currentConfig.id, payload);
|
||||
else await createAdminPhysicalVariant(payload);
|
||||
await reloadConfig(editing.id, editing.displayName || editing.modelKey);
|
||||
await onChanged();
|
||||
message.success(currentConfig ? "可用配置已更新" : "已创建唯一可用配置");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "保存可用配置失败");
|
||||
} finally {
|
||||
setConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
configRequestRef.current += 1;
|
||||
void reload();
|
||||
setEditing(null);
|
||||
setConfig(null);
|
||||
setConfigLoading(false);
|
||||
setConfigDrawerOpen(false);
|
||||
configForm.resetFields();
|
||||
setEditorOpen(false);
|
||||
setKeyword("");
|
||||
setCapability("all");
|
||||
@@ -165,12 +90,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
};
|
||||
|
||||
const startCreate = () => {
|
||||
configRequestRef.current += 1;
|
||||
setEditing(null);
|
||||
setConfig(null);
|
||||
setConfigLoading(false);
|
||||
setConfigDrawerOpen(false);
|
||||
configForm.resetFields();
|
||||
form.setFieldsValue({
|
||||
modelKey: "",
|
||||
displayName: "",
|
||||
@@ -189,10 +109,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const startEdit = (item: ChannelModel) => {
|
||||
setEditing(item);
|
||||
setConfig(null);
|
||||
setConfigDrawerOpen(false);
|
||||
configForm.setFieldsValue({ name: item.displayName || item.modelKey, enabled: true });
|
||||
void reloadConfig(item.id, item.displayName || item.modelKey);
|
||||
form.setFieldsValue({
|
||||
modelKey: item.modelKey,
|
||||
displayName: item.displayName,
|
||||
@@ -204,7 +120,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000,
|
||||
cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000,
|
||||
enabled: item.enabled,
|
||||
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey) : undefined,
|
||||
capabilityConfig: item.capability === "text" || item.capability === "image" || item.capability === "video" ? normalizeModelCapabilityConfig(item.capabilityConfig || defaultModelCapabilityConfig(item.protocol, item.modelKey)) : undefined,
|
||||
});
|
||||
setEditorOpen(true);
|
||||
};
|
||||
@@ -225,7 +141,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 === "text" || values.capability === "image" || values.capability === "video" ? values.capabilityConfig : undefined,
|
||||
capabilityConfig: values.capability === "text" || values.capability === "image" || values.capability === "video" ? normalizeModelCapabilityConfig(values.capabilityConfig!) : undefined,
|
||||
};
|
||||
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
|
||||
else await createAdminChannelModel(channel.id, payload);
|
||||
@@ -249,7 +165,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
modelKey: values.modelKey.trim(),
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
capabilityConfig: values.capabilityConfig,
|
||||
capabilityConfig: values.capabilityConfig ? normalizeModelCapabilityConfig(values.capabilityConfig) : undefined,
|
||||
});
|
||||
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
|
||||
} catch (error) {
|
||||
@@ -330,7 +246,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<Button size="small" onClick={() => startEdit(item)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm title="删除模型" description="删除后模型不再显示,历史账单仍会保留。该操作不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
|
||||
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
|
||||
<Button size="small" danger title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
@@ -421,7 +337,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
title={editing ? `编辑模型 / ${editing.displayName || editing.modelKey}` : "新增模型"}
|
||||
open={editorOpen}
|
||||
size="min(1080px, 100vw)"
|
||||
onClose={() => !configSaving && setEditorOpen(false)}
|
||||
onClose={() => !saving && setEditorOpen(false)}
|
||||
rootClassName="admin-drawer"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
@@ -481,34 +397,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="admin-form-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold">可用配置</h2>
|
||||
</div>
|
||||
{!editing ? (
|
||||
<div className="rounded-md bg-muted/20 px-4 py-4 text-sm text-foreground/60">请先保存模型基础信息,保存后即可配置唯一的路由入口。</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between gap-4 rounded-lg bg-muted/15 p-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">{currentConfig?.name || "尚未创建路由配置"}</span>
|
||||
{currentConfig ? <AdminStatusBadge label={currentConfig.enabled ? "启用" : "停用"} tone={currentConfig.enabled ? "success" : "neutral"} /> : null}
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-foreground/45">{currentConfig?.usageCount ? `已被 ${currentConfig.usageCount} 条前台供应配置引用` : "尚未被前台供应配置引用"}</div>
|
||||
</div>
|
||||
<Button
|
||||
loading={configLoading}
|
||||
onClick={() => {
|
||||
setConfigDrawerOpen(true);
|
||||
void reloadConfig(editing.id, editing.displayName || editing.modelKey);
|
||||
}}
|
||||
>
|
||||
编辑配置
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-form-section">
|
||||
<div className="mb-4">
|
||||
<h2 className="text-sm font-semibold">计费</h2>
|
||||
@@ -558,32 +446,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
</section>
|
||||
</Form>
|
||||
</Drawer>
|
||||
<Drawer
|
||||
title="可用配置"
|
||||
open={configDrawerOpen}
|
||||
size="min(560px, 100vw)"
|
||||
onClose={() => !configSaving && setConfigDrawerOpen(false)}
|
||||
rootClassName="admin-secondary-drawer"
|
||||
footer={<div className="flex justify-end gap-2"><Button onClick={() => setConfigDrawerOpen(false)}>取消</Button><Button type="primary" loading={configSaving} onClick={() => void saveConfig()}>保存路由配置</Button></div>}
|
||||
>
|
||||
<Form form={configForm} layout="vertical" requiredMark={false}>
|
||||
<div className="mb-5 rounded-lg bg-muted/15 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">唯一可用配置</span>
|
||||
<AdminStatusBadge label={currentConfig ? (currentConfig.enabled ? "启用" : "停用") : "未创建"} tone={currentConfig?.enabled ? "success" : "neutral"} />
|
||||
</div>
|
||||
<span className="text-xs text-foreground/45">{currentConfig?.usageCount ? `引用 ${currentConfig.usageCount}` : "未引用"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name="name" label="配置名称" rules={[{ required: true, message: "请填写配置名称" }]}><Input placeholder="例如:标准能力、高清能力、多参考图" /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch /></Form.Item>
|
||||
<div className="rounded-lg bg-muted/10 px-4 py-4">
|
||||
<div className="mb-2 text-xs font-semibold text-foreground/65">继承的模型能力</div>
|
||||
{editingCapabilitySpec ? <CapabilitySummary spec={editingCapabilitySpec} /> : <span className="text-xs text-foreground/45">请先在“能力与协议”中完成模型能力参数。</span>}
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,18 +6,18 @@ import { useDeferredValue, useEffect, useMemo, useState, type ReactNode } from "
|
||||
|
||||
import { PaginationBar } from "@/components/layout/workspace-page";
|
||||
import { ModelIconPicker, ModelLogo } from "@/components/model-logo";
|
||||
import { CapabilityCardPicker } from "@/components/model-protocol-picker";
|
||||
import { formatCredits } from "@/constant/credits";
|
||||
import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
|
||||
import { AdminDataTable, AdminFilterChip, AdminRowActions, AdminStatusBadge, AdminTableEmpty } from "@/pages/admin/components/admin-ui";
|
||||
import { listAdminChannels } from "@/services/api/auth";
|
||||
import { listAdminChannelModels, type ChannelModel } from "@/services/api/wallet";
|
||||
import {
|
||||
createAdminLogicalModel,
|
||||
listAdminLogicalModels,
|
||||
listAdminPhysicalVariants,
|
||||
simulateAdminLogicalModel,
|
||||
updateAdminLogicalModel,
|
||||
type AdminLogicalModel,
|
||||
type AdminPhysicalVariant,
|
||||
type CapabilitySpec,
|
||||
type LogicalModelMutation,
|
||||
type ModelRequestIntent,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
CapabilitySummary,
|
||||
DefaultOptionsEditor,
|
||||
capabilityLabel,
|
||||
capabilitySpecFromChannelModel,
|
||||
capabilitySourceError,
|
||||
emptyCapabilitySpec,
|
||||
mergeCapabilitySpecs,
|
||||
@@ -38,7 +39,7 @@ import {
|
||||
type CapabilityKind,
|
||||
} from "./model-routing-capabilities";
|
||||
|
||||
type RouteRuleRow = { physicalVariantId: string; enabled: boolean; priority: number; weight: number };
|
||||
type RouteRuleRow = { channelModelId: string; enabled: boolean; priority: number; weight: number };
|
||||
type LogicalModelFormValues = {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -57,13 +58,6 @@ type LogicalModelFormValues = {
|
||||
defaultOptions: Record<string, unknown>;
|
||||
routes: RouteRuleRow[];
|
||||
};
|
||||
const capabilityOptions = [
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "图片", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "音频", value: "audio" },
|
||||
];
|
||||
|
||||
export default function LogicalModelsPage() {
|
||||
const { message } = App.useApp();
|
||||
const [keyword, setKeyword] = useState("");
|
||||
@@ -72,7 +66,7 @@ export default function LogicalModelsPage() {
|
||||
const deferredKeyword = useDeferredValue(keyword.trim().toLowerCase());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [models, setModels] = useState<AdminLogicalModel[]>([]);
|
||||
const [variants, setVariants] = useState<AdminPhysicalVariant[]>([]);
|
||||
const [channelModels, setChannelModels] = useState<ChannelModel[]>([]);
|
||||
const [channelNames, setChannelNames] = useState<Record<string, string>>({});
|
||||
const [channelEnabled, setChannelEnabled] = useState<Record<string, boolean>>({});
|
||||
const [editingModel, setEditingModel] = useState<AdminLogicalModel | null | undefined>();
|
||||
@@ -89,11 +83,16 @@ export default function LogicalModelsPage() {
|
||||
const reload = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [modelResult, variantResult, channelResult] = await Promise.all([listAdminLogicalModels(), listAdminPhysicalVariants(), listAdminChannels({ page: 1, limit: 100 })]);
|
||||
const [modelResult, firstChannelPage] = await Promise.all([listAdminLogicalModels(), listAdminChannels({ page: 1, limit: 100 })]);
|
||||
const remainingChannelPages = await Promise.all(
|
||||
Array.from({ length: Math.max(0, Math.ceil(firstChannelPage.total / firstChannelPage.limit) - 1) }, (_, index) => listAdminChannels({ page: index + 2, limit: firstChannelPage.limit })),
|
||||
);
|
||||
const channels = [firstChannelPage, ...remainingChannelPages].flatMap((result) => result.channels);
|
||||
const channelModelResults = await Promise.all(channels.map((channel) => listAdminChannelModels(channel.id)));
|
||||
setModels(modelResult.models);
|
||||
setVariants(variantResult.variants);
|
||||
setChannelNames(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.name])));
|
||||
setChannelEnabled(Object.fromEntries(channelResult.channels.map((channel) => [channel.id, channel.enabled !== false])));
|
||||
setChannelModels(channelModelResults.flatMap((result) => result.models));
|
||||
setChannelNames(Object.fromEntries(channels.map((channel) => [channel.id, channel.name])));
|
||||
setChannelEnabled(Object.fromEntries(channels.map((channel) => [channel.id, channel.enabled !== false])));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取前台模型配置失败");
|
||||
} finally {
|
||||
@@ -107,14 +106,15 @@ export default function LogicalModelsPage() {
|
||||
|
||||
const filteredModels = useMemo(() => models.filter((item) => !deferredKeyword || [item.name, item.code, item.capability].some((value) => value.toLowerCase().includes(deferredKeyword))), [models, deferredKeyword]);
|
||||
const paginatedModels = useMemo(() => filteredModels.slice((page - 1) * pageSize, page * pageSize), [filteredModels, page, pageSize]);
|
||||
const modelVariants = useMemo(() => variants.filter((item) => item.capability === modelCapability), [variants, modelCapability]);
|
||||
const modelChannelModels = useMemo(() => channelModels.filter((item) => item.capability === modelCapability), [channelModels, modelCapability]);
|
||||
const modelSourceSpecs = useMemo(
|
||||
() =>
|
||||
modelRoutes
|
||||
.filter((route) => route.enabled && route.weight > 0)
|
||||
.map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec)
|
||||
.map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false))
|
||||
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
|
||||
.filter((item): item is CapabilitySpec => Boolean(item)),
|
||||
[channelEnabled, modelRoutes, variants],
|
||||
[channelEnabled, channelModels, modelRoutes],
|
||||
);
|
||||
|
||||
const openModel = (item?: AdminLogicalModel) => {
|
||||
@@ -148,7 +148,7 @@ export default function LogicalModelsPage() {
|
||||
const saveModel = async () => {
|
||||
const values = await modelForm.validateFields();
|
||||
if (values.enabled && !values.routes.length) {
|
||||
message.error("请至少添加一条供应配置");
|
||||
message.error("请至少添加一条供应线路");
|
||||
return;
|
||||
}
|
||||
const sourceError = capabilitySourceError(values.capability, modelSourceSpecs, values.capabilitySpec);
|
||||
@@ -218,9 +218,9 @@ export default function LogicalModelsPage() {
|
||||
),
|
||||
},
|
||||
{ title: "类型", dataIndex: "capability", width: 90, render: (value: CapabilityKind) => capabilityLabel(value) },
|
||||
{ title: "创作端能力", render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
|
||||
{ title: "创作端能力", width: 360, render: (_, item) => <CapabilitySummary spec={item.capabilitySpec} /> },
|
||||
{
|
||||
title: "供应配置",
|
||||
title: "供应线路",
|
||||
width: 110,
|
||||
render: (_, item) => (
|
||||
<div className="text-xs">
|
||||
@@ -239,7 +239,7 @@ export default function LogicalModelsPage() {
|
||||
<AdminRowActions
|
||||
primary={{ label: "编辑", icon: <Pencil className="size-3.5" />, onClick: () => openModel(item) }}
|
||||
actions={[
|
||||
{ key: "simulate", label: "模拟供应配置匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
|
||||
{ key: "simulate", label: "模拟供应线路匹配", icon: <FlaskConical className="size-3.5" />, onClick: () => openSimulation(item) },
|
||||
{ key: "toggle", label: item.enabled ? "停用" : "启用", onClick: () => void toggleModel(item) },
|
||||
]}
|
||||
/>
|
||||
@@ -276,13 +276,28 @@ export default function LogicalModelsPage() {
|
||||
rootClassName="admin-drawer"
|
||||
footer={<div className="flex justify-end gap-2"><Button disabled={saving} onClick={() => setEditingModel(undefined)}>取消</Button><Button type="primary" loading={saving} onClick={() => void saveModel()}>保存</Button></div>}
|
||||
>
|
||||
<Form form={modelForm} layout="vertical" requiredMark={false} className="space-y-3">
|
||||
<Form
|
||||
form={modelForm}
|
||||
layout="vertical"
|
||||
requiredMark={false}
|
||||
className="space-y-3"
|
||||
onValuesChange={(changedValues: Partial<LogicalModelFormValues>) => {
|
||||
const capability = changedValues.capability;
|
||||
if (!capability) return;
|
||||
modelForm.setFieldsValue({
|
||||
routes: [],
|
||||
capabilitySpec: emptyCapabilitySpec(capability),
|
||||
defaultOptions: {},
|
||||
billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{editingModel?.configurationError || editingModel?.availabilityError ? (
|
||||
<Alert
|
||||
className="mb-4"
|
||||
type="warning"
|
||||
showIcon
|
||||
message={editingModel.configurationError ? "当前供应配置无法覆盖全部创作端能力" : "当前供应配置暂不可结算"}
|
||||
message={editingModel.configurationError ? "当前供应线路无法覆盖全部创作端能力" : "当前供应线路暂不可结算"}
|
||||
description={editingModel.configurationError || editingModel.availabilityError}
|
||||
/>
|
||||
) : null}
|
||||
@@ -301,15 +316,10 @@ export default function LogicalModelsPage() {
|
||||
<Form.Item name="description" label="简短说明">
|
||||
<Input.TextArea autoSize={{ minRows: 2, maxRows: 4 }} placeholder="说明适合的创作场景,不描述供应渠道。" />
|
||||
</Form.Item>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Form.Item name="capability" label="类型">
|
||||
<Select
|
||||
options={capabilityOptions}
|
||||
onChange={(capability: CapabilityKind) =>
|
||||
modelForm.setFieldsValue({ routes: [], capabilitySpec: emptyCapabilitySpec(capability), defaultOptions: {}, billingMode: capability === "text" ? "token" : capability === "video" ? "per_second" : "fixed_request" })
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="capability" label="类型">
|
||||
<CapabilityCardPicker density="compact" />
|
||||
</Form.Item>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Form.Item name="sortOrder" label="前台排序">
|
||||
<InputNumber className="w-full" precision={0} />
|
||||
</Form.Item>
|
||||
@@ -318,8 +328,8 @@ export default function LogicalModelsPage() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
</DrawerSection>
|
||||
<DrawerSection icon={<GitBranch className="size-4" />} title="供应配置">
|
||||
<RouteFields variants={modelVariants} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
|
||||
<DrawerSection icon={<GitBranch className="size-4" />} title="供应线路">
|
||||
<RouteFields channelModels={modelChannelModels} channelNames={channelNames} channelEnabled={channelEnabled} form={modelForm} capability={modelCapability} />
|
||||
</DrawerSection>
|
||||
<DrawerSection title="创作端可选能力">
|
||||
<Form.Item name="capabilitySpec" noStyle>
|
||||
@@ -338,7 +348,7 @@ export default function LogicalModelsPage() {
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={simulatingModel ? `供应配置匹配模拟 - ${simulatingModel.name}` : "供应配置匹配模拟"}
|
||||
title={simulatingModel ? `供应线路匹配模拟 - ${simulatingModel.name}` : "供应线路匹配模拟"}
|
||||
open={Boolean(simulatingModel)}
|
||||
className="workspace-modal workspace-modal-wide admin-simulation-modal"
|
||||
rootClassName="admin-modal-root"
|
||||
@@ -382,7 +392,7 @@ export default function LogicalModelsPage() {
|
||||
</Tag>
|
||||
</div>
|
||||
{simulationResult.productMatch.reasons?.length ? <p className="mb-4 text-sm text-error">{simulationResult.productMatch.reasons.join(";")}</p> : null}
|
||||
<Table size="small" pagination={false} rowKey="routeId" dataSource={simulationResult.candidates} columns={simulationColumns(simulatingModel)} />
|
||||
<Table size="small" pagination={false} rowKey="routeId" dataSource={simulationResult.candidates} columns={simulationColumns()} />
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -408,74 +418,75 @@ function DrawerSection({ icon, title, description, children }: { icon?: ReactNod
|
||||
}
|
||||
|
||||
function RouteFields({
|
||||
variants,
|
||||
channelModels,
|
||||
channelNames,
|
||||
channelEnabled,
|
||||
form,
|
||||
capability,
|
||||
}: {
|
||||
variants: AdminPhysicalVariant[];
|
||||
channelModels: ChannelModel[];
|
||||
channelNames: Record<string, string>;
|
||||
channelEnabled: Record<string, boolean>;
|
||||
form: FormInstance<LogicalModelFormValues>;
|
||||
capability: CapabilityKind;
|
||||
}) {
|
||||
const selectOptions = variants.map((item) => {
|
||||
const unavailableReason = channelEnabled[item.channelId] === false ? "渠道已停用" : !item.modelEnabled ? "渠道模型已停用" : !item.enabled ? "规格已停用" : "";
|
||||
const selectOptions = channelModels.map((item) => {
|
||||
const unavailableReason = channelEnabled[item.channelId] === false ? "渠道已停用" : !item.enabled ? "渠道模型已停用" : "";
|
||||
return {
|
||||
value: item.id,
|
||||
label: `${item.name} · ${channelNames[item.channelId] || item.channelId} / ${item.modelName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
|
||||
label: `${channelNames[item.channelId]} / ${item.displayName || item.modelKey}${unavailableReason ? `(${unavailableReason})` : ""}`,
|
||||
disabled: Boolean(unavailableReason),
|
||||
};
|
||||
});
|
||||
const availableVariantCount = selectOptions.filter((item) => !item.disabled).length;
|
||||
const availableChannelModelCount = selectOptions.filter((item) => !item.disabled).length;
|
||||
return (
|
||||
<Form.List name="routes">
|
||||
{(fields, { add, remove }) => {
|
||||
const currentRoutes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
|
||||
const selectedVariantCount = new Set(currentRoutes.map((route) => route?.physicalVariantId).filter(Boolean)).size;
|
||||
const canAdd = selectedVariantCount < availableVariantCount;
|
||||
const selectedChannelModelCount = new Set(currentRoutes.map((route) => route?.channelModelId).filter(Boolean)).size;
|
||||
const canAdd = selectedChannelModelCount < availableChannelModelCount;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-foreground/50">共 {fields.length} 条供应配置</span>
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!canAdd} onClick={() => add({ physicalVariantId: "", enabled: true, priority: 100, weight: 100 })}>
|
||||
添加供应配置
|
||||
<span className="text-xs text-foreground/50">共 {fields.length} 条供应线路</span>
|
||||
<Button size="small" icon={<Plus className="size-3.5" />} disabled={!canAdd} onClick={() => add({ channelModelId: "", enabled: true, priority: 100, weight: 100 })}>
|
||||
添加供应线路
|
||||
</Button>
|
||||
</div>
|
||||
{fields.length ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{fields.map((field) => {
|
||||
const routes = (form.getFieldValue("routes") || []) as RouteRuleRow[];
|
||||
const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.physicalVariantId)).filter(Boolean));
|
||||
const selectedByOthers = new Set(routes.map((route, index) => (index === field.name ? "" : route?.channelModelId)).filter(Boolean));
|
||||
const options = selectOptions.map((option) => ({ ...option, disabled: option.disabled || selectedByOthers.has(option.value) }));
|
||||
const selected = variants.find((item) => item.id === routes[field.name]?.physicalVariantId);
|
||||
const selected = channelModels.find((item) => item.id === routes[field.name]?.channelModelId);
|
||||
return (
|
||||
<div key={field.key} className="rounded-lg border border-border bg-muted/5 p-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-semibold">供应配置 {fields.indexOf(field) + 1}</div>
|
||||
<div className="text-xs font-semibold">供应线路 {fields.indexOf(field) + 1}</div>
|
||||
<div className="mt-0.5 truncate text-xs text-foreground/50">
|
||||
{selected ? `${selected.name} · ${channelNames[selected.channelId] || selected.channelId} / ${selected.modelName || selected.modelKey}` : "选择一个可承接请求的配置"}
|
||||
{selected ? `${channelNames[selected.channelId]} / ${selected.displayName || selected.modelKey}` : "选择一个可承接请求的渠道模型"}
|
||||
</div>
|
||||
</div>
|
||||
<Button type="text" size="small" danger onClick={() => remove(field.name)}>
|
||||
移除
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Item name={[field.name, "physicalVariantId"]} rules={[{ required: true, message: "请选择供应配置" }]} className="mb-3">
|
||||
<Form.Item name={[field.name, "channelModelId"]} rules={[{ required: true, message: "请选择渠道模型" }]} className="mb-3">
|
||||
<Select
|
||||
aria-label={`供应配置 ${fields.indexOf(field) + 1}`}
|
||||
aria-label={`供应线路 ${fields.indexOf(field) + 1}`}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择供应配置"
|
||||
placeholder="选择渠道模型"
|
||||
options={options}
|
||||
onChange={(physicalVariantId) => {
|
||||
onChange={(channelModelId) => {
|
||||
const nextRoutes = [...(form.getFieldValue("routes") || [])];
|
||||
nextRoutes[field.name] = { ...nextRoutes[field.name], physicalVariantId };
|
||||
nextRoutes[field.name] = { ...nextRoutes[field.name], channelModelId };
|
||||
const specs = nextRoutes
|
||||
.filter((route) => route.enabled && route.weight > 0)
|
||||
.map((route) => variants.find((item) => item.id === route.physicalVariantId && item.enabled && item.modelEnabled && channelEnabled[item.channelId] !== false)?.capabilitySpec)
|
||||
.map((route) => channelModels.find((item) => item.id === route.channelModelId && item.enabled && channelEnabled[item.channelId] !== false))
|
||||
.map((item) => (item ? capabilitySpecFromChannelModel(item) : undefined))
|
||||
.filter((item): item is CapabilitySpec => Boolean(item));
|
||||
form.setFieldValue("routes", nextRoutes);
|
||||
if (!hasCapabilityRules(form.getFieldValue("capabilitySpec"))) form.setFieldValue("capabilitySpec", mergeCapabilitySpecs(capability, specs));
|
||||
@@ -498,7 +509,7 @@ function RouteFields({
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{!fields.length ? <div className="rounded-md bg-muted/20 px-3 py-4 text-center text-xs text-foreground/50">尚未添加供应配置</div> : null}
|
||||
{!fields.length ? <div className="rounded-md bg-muted/20 px-3 py-4 text-center text-xs text-foreground/50">尚未添加供应线路</div> : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -569,7 +580,7 @@ function PricingFields() {
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应配置价格计算。故障切换到更高价格配置时会重新校验余额。</div>
|
||||
<div className="rounded-md bg-muted/20 px-3 py-3 text-xs leading-5 text-foreground/55">用户费用按实际命中的供应线路价格计算。故障切换到更高价格线路时会重新校验余额。</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
@@ -613,7 +624,7 @@ function logicalModelToForm(item: AdminLogicalModel): LogicalModelFormValues {
|
||||
cachedPriceMicrocredits: item.cachedPriceMicrocredits,
|
||||
capabilitySpec: item.capabilitySpec,
|
||||
defaultOptions: item.defaultOptions,
|
||||
routes: item.routes.map((route) => ({ physicalVariantId: route.physicalVariantId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
|
||||
routes: item.routes.map((route) => ({ channelModelId: route.channelModelId, enabled: route.enabled, priority: route.priority, weight: route.weight })),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -643,9 +654,12 @@ function hasCapabilityRules(spec?: CapabilitySpec) {
|
||||
return Boolean(spec && ((spec.operations?.length || 0) > 0 || Object.keys(spec.inputs || {}).length > 0 || Object.keys(spec.options || {}).length > 0));
|
||||
}
|
||||
|
||||
function simulationColumns(model: AdminLogicalModel): ColumnsType<RouteSimulationResult["candidates"][number]> {
|
||||
function simulationColumns(): ColumnsType<RouteSimulationResult["candidates"][number]> {
|
||||
return [
|
||||
{ title: "供应配置", render: (_, candidate) => model.routes.find((route) => route.id === candidate.routeId)?.physicalVariantName || "未知配置" },
|
||||
{
|
||||
title: "供应线路",
|
||||
render: (_, candidate) => `${candidate.channelModelName}(${candidate.channelModelKey})`,
|
||||
},
|
||||
{ title: "优先级", dataIndex: "priority", width: 80 },
|
||||
{ title: "权重", dataIndex: "weight", width: 70 },
|
||||
{
|
||||
|
||||
@@ -66,7 +66,7 @@ export function emptyCapabilitySpec(capability: CapabilityKind): CapabilitySpec
|
||||
}
|
||||
|
||||
function imageSizeOptionValues(values: string[], allowCustom: boolean) {
|
||||
const concreteValues = Array.from(new Set(values.map((value) => value.trim()).filter((value) => value && value !== "*")));
|
||||
const concreteValues = Array.from(new Set(values.map((value) => String(normalizeScalar(value)).trim()).filter((value) => value && value !== "*")));
|
||||
const result = concreteValues.length || !allowCustom ? concreteValues : [...STANDARD_IMAGE_SIZE_VALUES];
|
||||
if (allowCustom) result.push("*");
|
||||
return result;
|
||||
@@ -437,11 +437,13 @@ export function CapabilitySummary({ spec }: { spec: CapabilitySpec }) {
|
||||
if (spec.operations?.length) labels.unshift(spec.operations.map(operationLabel).join("/"));
|
||||
if (!labels.length) return <span className="text-xs text-foreground/45">基础能力</span>;
|
||||
return (
|
||||
<div className="flex max-w-lg flex-wrap gap-1">
|
||||
<div className="flex min-w-0 max-w-full flex-wrap gap-1">
|
||||
{labels.slice(0, 4).map((label) => (
|
||||
<Tag key={label}>{label}</Tag>
|
||||
<Tag key={label} className="max-w-full whitespace-normal break-all text-left leading-5">
|
||||
{label}
|
||||
</Tag>
|
||||
))}
|
||||
{labels.length > 4 ? <Tag>+{labels.length - 4}</Tag> : null}
|
||||
{labels.length > 4 ? <Tag className="shrink-0">+{labels.length - 4}</Tag> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export default function DrawingEngineSettingsPage() {
|
||||
|
||||
const dirty = engine !== current.defaultEngine || tldrawLicenseKey !== (current.tldrawLicenseKey || "");
|
||||
return (
|
||||
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器">
|
||||
<AdminPageFrame title="绘图工具" description="设置新建绘图节点使用的默认编辑器" scroll>
|
||||
<div className="pt-4">
|
||||
<SettingsSectionCard
|
||||
icon={<Brush className="size-4" />}
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function LibTVSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。">
|
||||
<AdminPageFrame title="第三方参数配置" description="集中维护外部平台的服务端凭证和连接状态,后续平台可在此继续扩展。" scroll>
|
||||
<div className="pt-4">
|
||||
<SettingsSectionCard
|
||||
icon={<KeyRound className="size-4" />}
|
||||
|
||||
@@ -59,7 +59,7 @@ export default function ResponseInterceptionSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="模型响应拦截">
|
||||
<AdminPageFrame title="模型响应拦截" scroll>
|
||||
<div className="pt-4">
|
||||
<SettingsSectionCard
|
||||
layout="stacked"
|
||||
|
||||
@@ -162,6 +162,7 @@ export default function RuntimePolicySettingsPage() {
|
||||
<AdminPageFrame
|
||||
title="资源与策略"
|
||||
description="账号配额、任务调度与请求安全策略"
|
||||
scroll
|
||||
actions={<div className="flex items-center gap-2"><Button icon={<RotateCcw className="size-4" />} disabled={loading || saving} onClick={reset}>重置</Button><Button icon={<InfinityIcon className="size-4" />} disabled={loading || saving} onClick={() => void useSelfMode()}>自用模式</Button></div>}
|
||||
>
|
||||
<Form form={form} layout="vertical" requiredMark={false} disabled={loading} onValuesChange={() => setDirty(true)}>
|
||||
|
||||
@@ -83,7 +83,7 @@ export default function StorageSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置">
|
||||
<AdminPageFrame title="存储服务" description="配置新增资源的默认存储位置" scroll>
|
||||
<div className="space-y-4 pt-4">
|
||||
<SettingsSectionCard
|
||||
layout="stacked"
|
||||
|
||||
@@ -195,12 +195,20 @@ export function CanvasTopBar({
|
||||
|
||||
<div className="canvas-topbar-cluster pointer-events-auto flex items-center gap-1.5">
|
||||
<Button type="text" className="!hidden !h-10 !w-10 !min-w-10 !rounded-xl !p-0 lg:!inline-flex" style={{ color: theme.node.text }} icon={<Search className="size-4" />} onClick={onOpenSearch} aria-label="搜索画布节点" title="搜索画布节点" />
|
||||
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CopyPlus className="size-4" />} onClick={onImportLibTV} aria-label="一键导入 LibTV 画布" title="一键导入 LibTV 画布">
|
||||
<span className="hidden xl:inline">导入 LibTV</span>
|
||||
</Button>
|
||||
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} onClick={onImportTapNow} aria-label="导入 TapNow 画布" title="导入 TapNow 画布">
|
||||
<span className="hidden xl:inline">导入 TapNow</span>
|
||||
</Button>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
placement="bottomRight"
|
||||
menu={{
|
||||
items: [
|
||||
{ key: "libtv", icon: <CopyPlus className="size-4" />, label: "导入 LibTV 画布", onClick: onImportLibTV },
|
||||
{ key: "tapnow", icon: <CloudDownload className="size-4" />, label: "导入 TapNow 画布", onClick: onImportTapNow },
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" className="canvas-topbar-import-button !h-10 !rounded-xl !px-2.5 !font-medium" style={{ color: theme.node.text }} icon={<CloudDownload className="size-4" />} aria-label="导入第三方画布" title="导入第三方画布">
|
||||
<span className="hidden lg:inline">导入第三方画布</span>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
<Dropdown
|
||||
trigger={["click"]}
|
||||
menu={{
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CSSProperties, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode, RefObject } from "react";
|
||||
import { memo, type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode, type RefObject } from "react";
|
||||
import { Link2 } from "lucide-react";
|
||||
|
||||
import { ConnectionPath } from "@/components/canvas/canvas-connections";
|
||||
@@ -75,7 +75,7 @@ type CanvasProjectWorldLayersProps = {
|
||||
const EMPTY_RESOURCE_REFERENCES: CanvasResourceReference[] = [];
|
||||
const EMPTY_CANVAS_NODES: CanvasNodeData[] = [];
|
||||
|
||||
export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
export const CanvasProjectWorldLayers = memo(function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
const { viewportScale } = props;
|
||||
return (
|
||||
<>
|
||||
@@ -126,7 +126,6 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
isRelated={props.relatedNodeIds.has(node.id)}
|
||||
isFocusRelated={props.activeNodeId === node.id}
|
||||
isConnectionTarget={props.connectionTargetNodeId === node.id || props.batchConnectionPreview?.targetNodeId === node.id}
|
||||
isConnecting={Boolean(props.connectingParams || props.batchConnectionPreview)}
|
||||
forceInputVisible={Boolean(props.batchConnectionPreview)}
|
||||
batchCount={props.batchChildCountById.get(node.id) || 0}
|
||||
batchExpanded={Boolean(node.metadata?.imageBatchExpanded)}
|
||||
@@ -136,7 +135,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
batchPrimary={Boolean(node.metadata?.batchRootId && props.nodeById.get(node.metadata.batchRootId)?.metadata?.primaryImageId === node.id)}
|
||||
batchMotion={props.batchMotionById.get(node.id)}
|
||||
showImageInfo={props.showImageInfo}
|
||||
reduceMediaEffects={props.reduceMediaEffects || props.isNodeDragging || props.mediaEffectsDisabledNodeId === node.id}
|
||||
reduceMediaEffects={props.reduceMediaEffects || props.mediaEffectsDisabledNodeId === node.id}
|
||||
resourceLabel={props.resourceReferenceByNodeId.get(node.id)}
|
||||
mentionReferences={props.mentionReferencesByNodeId.get(node.id) || EMPTY_RESOURCE_REFERENCES}
|
||||
renderNodeContent={props.renderCanvasNodeContent}
|
||||
@@ -187,7 +186,7 @@ export function CanvasProjectWorldLayers(props: CanvasProjectWorldLayersProps) {
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function BatchConnectionHandle({ scale, count, active, onPointerDown }: { scale: number; count: number; active: boolean; onPointerDown: (event: ReactPointerEvent) => void }) {
|
||||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||||
|
||||
@@ -560,6 +560,7 @@ function InfiniteCanvasPage() {
|
||||
setContextMenu,
|
||||
setDialogNodeId,
|
||||
});
|
||||
const replaceCanvasNodeMedia = useCallback((node: CanvasNodeData) => handleUploadRequest(node.id), [handleUploadRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectLoaded || !assetsHydrated || searchParams.get("mode") !== "handoff") return;
|
||||
@@ -1840,7 +1841,7 @@ function InfiniteCanvasPage() {
|
||||
onOpenTaskDetails={openCanvasNodeTaskDetails}
|
||||
onOpenVersions={openCanvasNodeVersions}
|
||||
onViewImage={viewCanvasNodeImage}
|
||||
onReplaceMedia={(node) => handleUploadRequest(node.id)}
|
||||
onReplaceMedia={replaceCanvasNodeMedia}
|
||||
onOpenTextEditor={openTextNodeEditor}
|
||||
onOpenDirector={editCanvasDirector}
|
||||
onOpenDrawing={openDrawingNode}
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function SharedCanvasPage() {
|
||||
const dragged = [node, ...(frameChildrenById.get(nodeId) || [])];
|
||||
dragRef.current = { primaryId: nodeId, nodeIds: dragged.map((item) => item.id), startX: event.clientX, startY: event.clientY, origins: new Map(dragged.map((item) => [item.id, item.position])), moved: false };
|
||||
document.body.style.cursor = "grabbing";
|
||||
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} isConnecting={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
|
||||
}} onResize={() => undefined} onToggleCollapsed={toggleFrame} onTitleChange={unauthorized} onHoverStart={keepToolbar} onHoverEnd={hideToolbar} onContextMenu={(event, nodeId) => openContextMenu(event, nodeId)} /> : <CanvasNode key={node.id} data={node} dragOffset={dragRef.current?.nodeIds.includes(node.id) && dragOffset ? dragOffset : undefined} scale={viewport.k} isSelected={selectedNodeId === node.id} isRelated={false} isFocusRelated={false} isConnectionTarget={false} showImageInfo={false} readOnly renderNodeContent={renderSharedNode} onMouseDown={(event, nodeId) => {
|
||||
event.stopPropagation();
|
||||
if (event.button !== 0) return;
|
||||
const target = nodes.find((item) => item.id === nodeId);
|
||||
|
||||
@@ -110,7 +110,7 @@ export function useCanvasRenderModel({
|
||||
return { left, top, width: Math.max(2, right - left), height: Math.max(2, bottom - top) };
|
||||
}, [nodes]);
|
||||
const renderBounds = useMemo(() => {
|
||||
const padding = (reduceMediaEffects ? Math.max(240, Math.max(viewportSize.width, viewportSize.height) * 0.4) : Math.max(800, Math.max(viewportSize.width, viewportSize.height) * 1.5)) / viewport.k;
|
||||
const padding = (reduceMediaEffects ? 240 : 360) / viewport.k;
|
||||
const viewLeft = -viewport.x / viewport.k - padding;
|
||||
const viewTop = -viewport.y / viewport.k - padding;
|
||||
const viewRight = viewLeft + viewportSize.width / viewport.k + padding * 2;
|
||||
@@ -121,11 +121,13 @@ export function useCanvasRenderModel({
|
||||
const frames: CanvasNodeData[] = [];
|
||||
const regular: CanvasNodeData[] = [];
|
||||
nodes.forEach((node) => {
|
||||
if (renderHiddenNodeIds.has(node.id) || node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom) return;
|
||||
if (renderHiddenNodeIds.has(node.id)) return;
|
||||
const retained = selectedNodeIds.has(node.id) || Boolean(dragPreview?.nodeIds.has(node.id));
|
||||
if (!retained && (node.position.x + node.width <= renderBounds.left || node.position.x >= renderBounds.right || node.position.y + node.height <= renderBounds.top || node.position.y >= renderBounds.bottom)) return;
|
||||
(isFrameNode(node) ? frames : regular).push(node);
|
||||
});
|
||||
return [...frames, ...regular];
|
||||
}, [nodes, renderBounds, renderHiddenNodeIds]);
|
||||
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
|
||||
|
||||
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
|
||||
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
|
||||
|
||||
@@ -39,12 +39,10 @@ export type PublicLogicalModel = {
|
||||
|
||||
export type AdminLogicalRoute = {
|
||||
id: string;
|
||||
physicalVariantId: string;
|
||||
physicalVariantName: string;
|
||||
channelModelId: string;
|
||||
channelId: string;
|
||||
physicalModelKey: string;
|
||||
physicalModelName: string;
|
||||
channelModelKey: string;
|
||||
channelModelName: string;
|
||||
enabled: boolean;
|
||||
priority: number;
|
||||
weight: number;
|
||||
@@ -61,29 +59,6 @@ export type AdminLogicalModel = PublicLogicalModel & {
|
||||
routes: AdminLogicalRoute[];
|
||||
};
|
||||
|
||||
export type AdminPhysicalVariant = {
|
||||
id: string;
|
||||
channelModelId: string;
|
||||
channelId: string;
|
||||
modelKey: string;
|
||||
modelName: string;
|
||||
name: string;
|
||||
capability: CapabilitySpec["capability"];
|
||||
protocol: string;
|
||||
enabled: boolean;
|
||||
modelEnabled: boolean;
|
||||
usageCount: number;
|
||||
capabilitySpec: CapabilitySpec;
|
||||
};
|
||||
|
||||
export type PhysicalVariantMutation = {
|
||||
channelModelId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
/** 能力范围由渠道模型能力参数自动生成;保留可选字段兼容旧调用方。 */
|
||||
capabilitySpec?: CapabilitySpec;
|
||||
};
|
||||
|
||||
export type LogicalModelMutation = {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -100,12 +75,12 @@ export type LogicalModelMutation = {
|
||||
cachedPriceMicrocredits: number;
|
||||
capabilitySpec: CapabilitySpec;
|
||||
defaultOptions: Record<string, unknown>;
|
||||
routes: Array<{ physicalVariantId: string; enabled: boolean; priority: number; weight: number }>;
|
||||
routes: Array<{ channelModelId: string; enabled: boolean; priority: number; weight: number }>;
|
||||
};
|
||||
|
||||
export type RouteSimulationResult = {
|
||||
productMatch: { matched: boolean; reasons?: string[] };
|
||||
candidates: Array<{ routeId: string; variantId: string; channelModelId: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>;
|
||||
candidates: Array<{ routeId: string; channelModelId: string; channelModelKey: string; channelModelName: string; priority: number; weight: number; enabled: boolean; matched: boolean; blocked: boolean; inPool: boolean; reasons?: string[] }>;
|
||||
};
|
||||
|
||||
export function listLogicalModels() {
|
||||
@@ -128,18 +103,6 @@ export function updateAdminLogicalModel(id: string, input: LogicalModelMutation)
|
||||
return request<{ model: AdminLogicalModel }>(apiClient.patch(`/admin/logical-models/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function listAdminPhysicalVariants(channelModelId?: string) {
|
||||
return request<{ variants: AdminPhysicalVariant[] }>(apiClient.get("/admin/logical-models/physical-variants", { params: channelModelId ? { channelModelId } : undefined }));
|
||||
}
|
||||
|
||||
export function createAdminPhysicalVariant(input: PhysicalVariantMutation) {
|
||||
return request<{ variant: AdminPhysicalVariant }>(apiClient.post("/admin/logical-models/physical-variants", input));
|
||||
}
|
||||
|
||||
export function updateAdminPhysicalVariant(id: string, input: PhysicalVariantMutation) {
|
||||
return request<{ variant: AdminPhysicalVariant }>(apiClient.patch(`/admin/logical-models/physical-variants/${encodeURIComponent(id)}`, input));
|
||||
}
|
||||
|
||||
export function simulateAdminLogicalModel(id: string, intent: ModelRequestIntent) {
|
||||
return request<RouteSimulationResult>(apiClient.post(`/admin/logical-models/${encodeURIComponent(id)}/simulate`, intent));
|
||||
}
|
||||
|
||||
@@ -1390,7 +1390,8 @@
|
||||
|
||||
.app-workspace-shell.is-spatial .app-workspace-rail .app-workspace-rail-tile {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
height: auto !important;
|
||||
min-height: 40px;
|
||||
border-radius: var(--r-md);
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
@@ -7725,6 +7726,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.admin-page-root.admin-page-root-scrollable > div {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.admin-page-frame {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
@@ -7733,6 +7739,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
padding: 12px 16px 0;
|
||||
}
|
||||
|
||||
.admin-page-frame-scrollable {
|
||||
height: auto;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.admin-page-header {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user