refactor(canvas): 生成配置 - 收敛插件工作流并移除系统模型分支
This commit is contained in:
@@ -70,11 +70,13 @@ func (s *Service) PluginProviderCatalog(scope, capability string, includeUnavail
|
||||
continue
|
||||
}
|
||||
item := PluginProviderCatalogItem{ID: provider.ID, Version: plugin.Manifest.Version, Name: provider.Label, Vendor: plugin.Manifest.Author, Categories: provider.Capabilities, Scopes: provider.Scopes, BaseURL: provider.BaseURL, Enabled: plugin.Status == "enabled", UnavailableReason: plugin.Error, Workflows: workflowsForProvider(plugin.Manifest.Contributes.Workflows, provider.ID)}
|
||||
item.Create = operationSummary(provider.Create)
|
||||
if provider.Poll != nil {
|
||||
item.Poll = operationSummary(*provider.Poll)
|
||||
item.Create, item.Poll, item.ContentType = operationSummary(provider.Create), operationSummaryPtr(provider.Poll), provider.Create.ContentType
|
||||
// The registry metadata is the canonical provider projection. This keeps
|
||||
// host-backed dispatch paths out of every user-facing catalog consumer.
|
||||
if adapter, ok := canonicalProviderAdapter(s.protocolRegistry(), provider.ID); ok {
|
||||
metadata := adapter.Metadata()
|
||||
item.Create, item.Poll, item.ContentType = metadata.Create, metadata.Poll, metadata.ContentType
|
||||
}
|
||||
item.ContentType = provider.Create.ContentType
|
||||
if includeUnavailable || item.Enabled {
|
||||
items = append(items, item)
|
||||
}
|
||||
@@ -83,6 +85,13 @@ func (s *Service) PluginProviderCatalog(scope, capability string, includeUnavail
|
||||
return items
|
||||
}
|
||||
|
||||
func canonicalProviderAdapter(registry *protocol.Registry, id string) (protocol.Adapter, bool) {
|
||||
if adapter, ok := protocol.Builtins().Resolve(id); ok {
|
||||
return adapter, true
|
||||
}
|
||||
return registry.Resolve(id)
|
||||
}
|
||||
|
||||
func containsPluginSurface(items []protocol.Surface, want protocol.Surface) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
@@ -114,6 +123,13 @@ func operationSummary(operation protocol.ManifestOperation) string {
|
||||
return strings.ToUpper(operation.Method) + " " + path
|
||||
}
|
||||
|
||||
func operationSummaryPtr(operation *protocol.ManifestOperation) string {
|
||||
if operation == nil {
|
||||
return ""
|
||||
}
|
||||
return operationSummary(*operation)
|
||||
}
|
||||
|
||||
func (s *Service) protocolRegistry() *protocol.Registry {
|
||||
if s.pluginRuntime != nil {
|
||||
if registry := s.pluginRuntime.registrySnapshot(); registry != nil {
|
||||
|
||||
@@ -35,6 +35,24 @@ func TestPluginViewIncludesDocumentationForEveryBundledProtocol(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBundledProviderCatalogExposesUpstreamOperation(t *testing.T) {
|
||||
center, err := newPluginRuntime(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := (&Service{pluginRuntime: center}).PluginProviderCatalog(string(protocol.SurfaceAdminSystemChannel), string(protocol.CapabilityVideo), false)
|
||||
for _, item := range catalog {
|
||||
if item.ID != "xai-video" {
|
||||
continue
|
||||
}
|
||||
if item.Create != "POST /v1/videos/generations" || strings.Contains(item.Create, "__host__") {
|
||||
t.Fatalf("xAI catalog create operation = %q", item.Create)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatal("xAI bundled provider missing from administrator catalog")
|
||||
}
|
||||
|
||||
func TestPluginRuntimeIsTheProtocolSourceOfTruth(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
center, err := newPluginRuntime(dataDir)
|
||||
|
||||
@@ -161,13 +161,13 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
|
||||
return (
|
||||
<div
|
||||
data-canvas-no-zoom
|
||||
className="aceternity-floating-panel rounded-lg border p-3 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }}
|
||||
className="canvas-config-composer aceternity-floating-panel rounded-xl p-4 backdrop-blur-2xl"
|
||||
style={{ background: theme.spatial.elevated, color: theme.node.text }}
|
||||
onMouseDown={stopCanvasInteraction}
|
||||
onPointerDown={stopCanvasInteraction}
|
||||
onWheel={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<div className="shrink-0 text-xs font-semibold">{simpleMode ? "快速生成" : "组装提示词"}</div>
|
||||
<div className="truncate text-[var(--fs-label)] opacity-55">{simpleMode ? "已连接素材会自动带入" : workflowVideoReferenceMode ? "已连接媒体会按工作流字段顺序自动带入" : "@ 引用已连接素材或已激活技能,发送前自动组装"}</div>
|
||||
@@ -178,7 +178,7 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
|
||||
</div>
|
||||
</div>
|
||||
{generationMode === "video" && onMetadataChange && !simpleMode ? (
|
||||
<div className="mb-2 border-y px-1 py-1.5" style={{ borderColor: theme.node.stroke }}>
|
||||
<div className="mb-3 rounded-lg px-2 py-2" style={{ background: theme.node.fill }}>
|
||||
<CanvasVideoPromptTools
|
||||
metadata={metadata}
|
||||
frameOptions={videoFrameOptions}
|
||||
@@ -192,13 +192,13 @@ export function CanvasConfigComposer({ value, inputs, skillReferences = [], gene
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="relative rounded-lg border" style={{ background: theme.node.fill, borderColor: theme.node.stroke }}>
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-3 top-2 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接素材或技能</div> : null}
|
||||
<div className="canvas-config-composer-editor relative rounded-lg" style={{ background: theme.node.fill }}>
|
||||
{!value.trim() ? <div className="pointer-events-none absolute left-4 top-3 text-sm leading-7" style={{ color: theme.node.placeholder }}>输入提示词,按 @ 引用连接素材或技能</div> : null}
|
||||
<div
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
className="thin-scrollbar min-h-28 max-h-[min(42vh,360px)] w-full overflow-y-auto whitespace-pre-wrap break-words px-3 py-2 text-sm leading-7 outline-none"
|
||||
className="thin-scrollbar min-h-32 max-h-[min(42vh,360px)] w-full overflow-y-auto whitespace-pre-wrap break-words px-4 py-3 text-sm leading-7 outline-none"
|
||||
style={{ color: theme.node.text }}
|
||||
onInput={() => {
|
||||
if (!composingRef.current) syncFromEditor();
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { useEffect, useState, type CSSProperties, type ReactNode } from "react";
|
||||
import { ChevronDown, Dice5, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Settings2, Sparkles, Video, Workflow as WorkflowIcon } from "lucide-react";
|
||||
import { ChevronDown, Dice5, Image as ImageIcon, LoaderCircle, MessageSquare, Music2, Play, Sparkles, Video, Workflow as WorkflowIcon } from "lucide-react";
|
||||
import { Button, Input, InputNumber, Segmented, Select, Slider, Switch, Tooltip } from "antd";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { configuredModelMatchesCapability, defaultConfig, modelOptionName, normalizeRunningHubCapability, resolveModelChannel, useEffectiveConfig, type AiConfig, type RunningHubCapability, type RunningHubWorkflow, type RunningHubWorkflowKind } from "@/stores/use-config-store";
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { defaultModelCapabilityConfig, modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue, workflowFieldChoiceValues, workflowFieldCurrentValue, workflowFieldKey, workflowFieldNumberBounds, workflowFieldRandomKey, workflowFieldSubmissionValue, workflowFieldValueError, workflowImageCapabilityConfig, workflowOutputSizeValue, workflowParameterFields, workflowVideoCapabilityConfig, workflowVideoFieldsFromJson, type WorkflowVideoFieldLike } from "@/lib/model-capabilities";
|
||||
import { defaultImageParamsForModel, modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, resolveModelGenerationDefaults, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { resolveCanvasWorkflowProvider } from "@/lib/canvas/canvas-workflow";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import type { CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover";
|
||||
import { CanvasAudioSettingsPopover, type CanvasAudioSettingKey } from "./canvas-audio-settings-popover";
|
||||
import { CanvasVideoSettingsPopover } from "./canvas-video-settings-popover";
|
||||
import type { CanvasGenerationMode, CanvasNodeData, CanvasNodeMetadata, CanvasVideoEditOperation, CanvasWorkspaceMode } from "@/types/canvas";
|
||||
|
||||
type CanvasConfigNodePanelProps = {
|
||||
@@ -65,10 +59,10 @@ function runningHubWorkflowEntryKey(workflow: RunningHubWorkflow): string {
|
||||
export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigChange, onGenerate, onComposerToggle, workspaceMode = "professional" }: CanvasConfigNodePanelProps) {
|
||||
const globalConfig = useEffectiveConfig();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
const mode = node.metadata?.generationMode || "image";
|
||||
const mode = node.metadata?.generationMode === "video" || node.metadata?.generationMode === "audio" ? node.metadata.generationMode : "image";
|
||||
const simpleMode = workspaceMode === "simple";
|
||||
const workflowProvider = mode === "text" ? "model" : resolveCanvasWorkflowProvider(node.metadata);
|
||||
const resolvedProvider = resolveCanvasWorkflowProvider(node.metadata);
|
||||
const workflowProvider = resolvedProvider === "comfyui" ? "comfyui" : "runninghub";
|
||||
const workflowCapability: RunningHubCapability = mode === "video" ? "video" : mode === "audio" ? "audio" : "image";
|
||||
const requirements: ModelRequirements = {
|
||||
capability: mode,
|
||||
@@ -104,7 +98,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
: [];
|
||||
const dynamicWorkflowFields = workflowParameterFields(selectedWorkflowFields);
|
||||
useEffect(() => {
|
||||
if (mode === "text") return;
|
||||
if (workflowProvider === "runninghub" && !node.metadata?.runningHubWorkflowId?.trim()) {
|
||||
const capability = normalizeRunningHubCapability(globalConfig.runningHub.capability);
|
||||
// 模式切换后优先按当前能力选择工作流,不能沿用全局默认的视频工作流,
|
||||
@@ -150,36 +143,27 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
.filter((item) => item.capability === workflowCapability || item.workflowId.trim() === node.metadata?.comfyBridgeWorkflowId?.trim())
|
||||
.map((item) => ({ label: item.title || item.workflowId, value: item.workflowId, kind: "workflow" as const, title: `${item.title || item.workflowId} · ${capabilityLabel(item.capability)}` }));
|
||||
const comfyBridgeOptions: WorkflowSelectOption[] = comfyBridgeEntries.length ? [{ label: <WorkflowOptionGroupLabel label="工作流" count={comfyBridgeEntries.length} />, options: comfyBridgeEntries }] : [];
|
||||
const videoProfile = mode === "video" ? (workflowProvider === "model" ? modelCapabilityConfigFor(config, config.model).video! : undefined) : 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 textCountValue = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.textCount) || 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 });
|
||||
const hasPrice = workflowProvider === "model" && creditsEnabled && credits !== null;
|
||||
const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text };
|
||||
const chipStyle = { background: theme.node.fill, color: theme.node.text };
|
||||
const hasAnyInput = Boolean(inputSummary.textCount || inputSummary.imageCount || inputSummary.videoCount || inputSummary.audioCount || inputSummary.characterCount);
|
||||
const hasComposerContent = Boolean((node.metadata?.composerContent ?? node.metadata?.prompt ?? "").trim());
|
||||
const workflowParameterError = workflowProvider === "model" ? "" : firstWorkflowParameterError(dynamicWorkflowFields, node.metadata?.workflowParameters || {});
|
||||
const workflowParameterError = firstWorkflowParameterError(dynamicWorkflowFields, node.metadata?.workflowParameters || {});
|
||||
const capabilityError = workflowParameterError || (workflowProvider === "runninghub"
|
||||
? (!globalConfig.runningHub.enabled ? "请先在设置中启用 RunningHub" : !node.metadata?.runningHubWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流或 App` : !selectedRunningHubWorkflow ? "当前画布引用的 RunningHub 条目已不存在,请重新选择" : selectedRunningHubCapability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedRunningHubCapability || "image")},请切换画布模式或重新选择条目` : undefined)
|
||||
: workflowProvider === "comfyui"
|
||||
? (!globalConfig.comfyBridge.enabled ? "请先在设置中启用 ComfyUI Bridge" : !globalConfig.comfyBridge.bridgeId ? "请选择在线 Bridge" : !node.metadata?.comfyBridgeWorkflowId ? `请选择${capabilityLabel(workflowCapability)}工作流` : !selectedComfyBridgeWorkflow ? "当前画布引用的 ComfyUI 条目已不存在,请重新选择" : selectedComfyBridgeWorkflow.capability !== workflowCapability ? `当前条目用途为${capabilityLabel(selectedComfyBridgeWorkflow.capability)},请切换画布模式或重新选择条目` : undefined)
|
||||
: modelCompatibilityError(config, config.model, requirements));
|
||||
: undefined);
|
||||
const canGenerate = (hasComposerContent || (mode === "audio" ? inputSummary.textCount > 0 : hasAnyInput)) && !capabilityError;
|
||||
|
||||
return (
|
||||
<div className={workflowProvider === "model" ? "flex h-full w-full cursor-move flex-col px-3 pb-3 pt-7 text-sm" : "thin-scrollbar flex h-full w-full cursor-move flex-col gap-3 overflow-y-auto px-3 pb-3 pt-7 text-sm"} style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<div className={workflowProvider === "model" ? "mb-2 flex items-center justify-between gap-3" : "flex min-h-8 items-center justify-between gap-3"}>
|
||||
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : workflowProvider === "model" ? "生成配置" : "工作流生成"}</div>
|
||||
<div className="canvas-config-node-panel thin-scrollbar flex h-full w-full cursor-move flex-col gap-3.5 overflow-y-auto px-4 pb-4 pt-8 text-sm" style={{ color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<div className="flex min-h-9 items-center justify-between gap-3">
|
||||
<div className="shrink-0 text-sm font-semibold">{simpleMode ? "快速生成" : "工作流生成"}</div>
|
||||
{simpleMode ? <span className="rounded-md px-2 py-1 text-[var(--fs-tiny)]" style={{ background: theme.node.fill, color: theme.node.muted }}>自动配置</span> : <div className="cursor-default" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<Segmented
|
||||
size="small"
|
||||
className="canvas-config-mode !rounded-md !p-0.5"
|
||||
value={mode}
|
||||
onChange={(value) => onConfigChange(node.id, workflowProvider === "model"
|
||||
? { generationMode: value as CanvasGenerationMode }
|
||||
: { generationMode: value as CanvasGenerationMode, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} })}
|
||||
onChange={(value) => onConfigChange(node.id, { generationMode: value as CanvasGenerationMode, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} })}
|
||||
options={[
|
||||
{
|
||||
value: "image",
|
||||
@@ -190,15 +174,6 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "text",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<MessageSquare className="size-3.5" />
|
||||
文本
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
value: "video",
|
||||
label: (
|
||||
@@ -222,70 +197,58 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div className={workflowProvider === "model" ? "mb-2 flex flex-wrap gap-1.5" : "flex min-w-0 flex-wrap items-center gap-2"}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<InputChip label="提示词" value={`${inputSummary.textCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考图" value={`${inputSummary.imageCount} 张`} style={chipStyle} />
|
||||
<InputChip label="参考视频" value={`${inputSummary.videoCount} 个`} style={chipStyle} />
|
||||
<InputChip label="参考音频" value={`${inputSummary.audioCount} 个`} style={chipStyle} />
|
||||
{inputSummary.characterCount ? <InputChip label="角色卡" value={`${inputSummary.characterCount} 个`} style={chipStyle} /> : null}
|
||||
<button type="button" className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-md border px-2 text-[var(--fs-label)]" style={chipStyle} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||
{simpleMode ? <MessageSquare className="size-3.5" /> : <Settings2 className="size-3.5" />}
|
||||
{simpleMode ? "编辑生成内容" : "组装提示词"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" className="canvas-config-prompt-button group flex min-h-11 w-full cursor-pointer items-center gap-2.5 rounded-lg px-3 text-left" style={{ background: theme.node.fill, color: theme.node.text }} onMouseDown={(event) => event.stopPropagation()} onClick={onComposerToggle}>
|
||||
<span className="grid size-7 shrink-0 place-items-center rounded-md" style={{ background: theme.node.panel }}>
|
||||
{simpleMode ? <MessageSquare className="size-3.5" /> : <Sparkles className="size-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[var(--fs-label)] font-semibold">{simpleMode ? "编辑生成内容" : "组装提示词"}</span>
|
||||
<span className="block truncate text-[var(--fs-tiny)]" style={{ color: theme.node.muted }}>{hasComposerContent ? "已填写,可继续编辑或引用素材" : "输入提示词,或用 @ 引用连接素材"}</span>
|
||||
</span>
|
||||
<ChevronDown className="size-3.5 -rotate-90 opacity-45 transition-transform group-hover:translate-x-0.5" />
|
||||
</button>
|
||||
|
||||
{mode === "video" && !simpleMode ? (
|
||||
<div className={workflowProvider === "model" ? "mb-2 cursor-default" : "cursor-default pt-1"} data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{workflowProvider === "model" ? (
|
||||
<Select
|
||||
size="small"
|
||||
className="canvas-compact-control canvas-control-select !h-9 !w-full"
|
||||
value={node.metadata?.videoEditOperation || defaultVideoOperation(inputSummary)}
|
||||
options={operationOptions}
|
||||
placement="bottomLeft"
|
||||
popupMatchSelectWidth={false}
|
||||
styles={{ popup: { root: { minWidth: 180, maxWidth: 260 } } }}
|
||||
popupRender={(menu) => (
|
||||
<div data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{menu}
|
||||
</div>
|
||||
)}
|
||||
onChange={(value) => onConfigChange(node.id, { videoEditOperation: value })}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-9 min-w-0 items-center gap-2 rounded-lg border px-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }} title="已连接的图片、视频和音频会按当前工作流字段映射自动传入">
|
||||
<ImageIcon className="size-3.5 shrink-0 opacity-75" />
|
||||
<span className="shrink-0 font-medium">全能参考</span>
|
||||
<span className="min-w-0 truncate opacity-60">已连接媒体自动映射</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="cursor-default" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="flex h-9 min-w-0 items-center gap-2 rounded-lg border px-2 text-[var(--fs-label)]" style={{ background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }} title="已连接的图片、视频和音频会按当前工作流字段映射自动传入">
|
||||
<ImageIcon className="size-3.5 shrink-0 opacity-75" />
|
||||
<span className="shrink-0 font-medium">全能参考</span>
|
||||
<span className="min-w-0 truncate opacity-60">已连接媒体自动映射</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{simpleMode ? (
|
||||
<div className={workflowProvider === "model" ? "mb-2 rounded-lg px-2 py-2 text-[var(--fs-label)]" : "rounded-lg px-2 py-2 text-[var(--fs-label)]"} style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
|
||||
<div className="rounded-lg px-3 py-2.5 text-[var(--fs-label)]" style={{ background: theme.node.fill, color: theme.node.muted }}>将使用当前默认模型与生成参数</div>
|
||||
) : (
|
||||
<div className={workflowProvider === "model" ? "mb-2" : "flex min-w-0 flex-col gap-3"}>
|
||||
{mode !== "text" ? <div data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex items-center gap-3" data-canvas-no-zoom onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
<span className="shrink-0 text-[var(--fs-tiny)] font-medium" style={{ color: theme.node.muted }}>工作流来源</span>
|
||||
<Segmented
|
||||
block
|
||||
size="small"
|
||||
className="canvas-config-provider"
|
||||
className="canvas-config-provider min-w-0 flex-1"
|
||||
value={workflowProvider}
|
||||
options={[{ label: "基础模型", value: "model" }, { label: "RunningHub", value: "runninghub" }, { label: "ComfyUI", value: "comfyui" }]}
|
||||
options={[{ label: "RunningHub", value: "runninghub" }, { label: "ComfyUI", value: "comfyui" }]}
|
||||
onChange={(value) => {
|
||||
const nextProvider = value as "model" | "runninghub" | "comfyui";
|
||||
if (nextProvider === "model") {
|
||||
onConfigChange(node.id, { workflowProvider: "model", workflowTitle: undefined, runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, comfyBridgeWorkflowId: undefined, workflowParameters: {} });
|
||||
} else if (nextProvider === "runninghub") {
|
||||
const nextProvider = value as "runninghub" | "comfyui";
|
||||
if (nextProvider === "runninghub") {
|
||||
onConfigChange(node.id, { workflowProvider: "runninghub", workflowTitle: "RunningHub 工作流", comfyBridgeWorkflowId: undefined, workflowParameters: {} });
|
||||
} else {
|
||||
onConfigChange(node.id, { workflowProvider: "comfyui", workflowTitle: "ComfyUI Bridge", runningHubWorkflowId: undefined, runningHubWorkflowKind: undefined, workflowParameters: {} });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div> : null}
|
||||
<div data-canvas-no-zoom className={workflowProvider === "model" ? "grid min-w-0 cursor-default grid-cols-[minmax(0,1fr)_148px] items-center gap-2" : "grid min-w-0 cursor-default items-center gap-3"} onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
</div>
|
||||
<div data-canvas-no-zoom className="grid min-w-0 cursor-default items-center gap-3" onMouseDown={(event) => event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()}>
|
||||
{workflowProvider === "runninghub" ? (
|
||||
<Select<string, WorkflowSelectOption>
|
||||
className="canvas-compact-control !h-9 w-full"
|
||||
@@ -336,28 +299,14 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
}}
|
||||
onChange={(value) => onConfigChange(node.id, { comfyBridgeWorkflowId: value, workflowParameters: {} })}
|
||||
/>
|
||||
) : (
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, mode === "image" ? { model, ...defaultImageParamsForModel(config, model) } : { model })} capability={mode} requirements={requirements} onMissingConfig={() => navigateToSettings({ continueCreation: true })} fullWidth showSelectedPrice={creditsEnabled} />
|
||||
)}
|
||||
{workflowProvider === "model" && mode === "text" ? (
|
||||
<div className="flex h-10 min-w-0 cursor-default items-center justify-between gap-2 rounded-lg border px-2.5" style={{ borderColor: theme.node.stroke, background: theme.node.fill }}>
|
||||
<span className="inline-flex items-center gap-1 text-[var(--fs-tiny)] font-semibold" style={{ color: theme.node.muted }}><MessageSquare className="size-3.5" />文本份数</span>
|
||||
<InputNumber size="small" min={1} max={15} value={textCountValue} onChange={(value) => onConfigChange(node.id, { textCount: Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 1))) })} aria-label="文本生成份数" />
|
||||
</div>
|
||||
) : workflowProvider === "model" && mode === "video" ? (
|
||||
<CanvasVideoSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
) : workflowProvider === "model" && mode === "image" ? (
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" autoAdjustOverflow={false} buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} />
|
||||
) : workflowProvider === "model" && mode === "audio" ? (
|
||||
<CanvasAudioSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(key, value) => onConfigChange(node.id, audioConfigPatch(key, value))} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workflowProvider !== "model" && dynamicWorkflowFields.length ? <WorkflowParameterControls fields={dynamicWorkflowFields} node={node} theme={theme} onConfigChange={onConfigChange} /> : null}
|
||||
{dynamicWorkflowFields.length ? <WorkflowParameterControls fields={dynamicWorkflowFields} node={node} theme={theme} onConfigChange={onConfigChange} /> : null}
|
||||
|
||||
{capabilityError ? <div className={workflowProvider === "model" ? "mb-2 rounded-md px-2 py-1.5 text-[var(--fs-tiny)]" : "rounded-md px-2 py-1.5 text-[var(--fs-tiny)]"} style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
|
||||
{capabilityError ? <div className="rounded-lg px-3 py-2 text-[var(--fs-tiny)]" style={{ background: theme.accent.danger + "18", color: theme.accent.danger }}>{capabilityError}</div> : null}
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -374,18 +323,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!creditsEnabled || workflowProvider !== "model" ? (
|
||||
<span>生成</span>
|
||||
) : hasPrice ? (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<CreditSymbol />
|
||||
{credits.toLocaleString()}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs" title="当前渠道没有模型价格数据">
|
||||
无价格
|
||||
</span>
|
||||
)}
|
||||
<span>生成</span>
|
||||
<Play className="size-4" />
|
||||
<span>开始生成</span>
|
||||
</>
|
||||
@@ -509,7 +447,7 @@ function workflowParameterOptionLabel(value: unknown) {
|
||||
|
||||
function InputChip({ label, value, style }: { label: string; value: string; style: CSSProperties }) {
|
||||
return (
|
||||
<div className="inline-flex h-7 items-center gap-1 rounded-md border px-2 text-[var(--fs-label)]" style={style}>
|
||||
<div className="inline-flex h-7 items-center gap-1 rounded-md px-2.5 text-[var(--fs-label)]" style={style}>
|
||||
<span>{label}</span>
|
||||
<span className="font-medium">{value}</span>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export type CanvasCreateCommand = {
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
badge?: string;
|
||||
section: "node" | "extension" | "workflow" | "project" | "resource";
|
||||
section: "node" | "workflow" | "project" | "resource";
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[]
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const projectCommands = commands.filter((command) => command.section === "project");
|
||||
const nodeCommands = commands.filter((command) => command.section === "node");
|
||||
const extensionCommands = commands.filter((command) => command.section === "extension");
|
||||
const workflowCommands = commands.filter((command) => command.section === "workflow");
|
||||
const resourceCommands = commands.filter((command) => command.section === "resource");
|
||||
|
||||
@@ -46,12 +45,6 @@ export function CanvasCreateMenu({ commands }: { commands: CanvasCreateCommand[]
|
||||
<MenuSection title="创作节点" color={theme.node.muted} />
|
||||
<CanvasCreateCommandGrid commands={nodeCommands} variant="node" />
|
||||
|
||||
{extensionCommands.length ? (
|
||||
<>
|
||||
<MenuSection title="展示与加工" color={theme.node.muted} spaced />
|
||||
<CanvasCreateCommandGrid commands={extensionCommands} variant="node" />
|
||||
</>
|
||||
) : null}
|
||||
{workflowCommands.length ? (
|
||||
<>
|
||||
<MenuSection title="工作流" color={theme.node.muted} spaced />
|
||||
@@ -70,7 +63,7 @@ function CanvasCreateCommandGrid({ commands, variant }: { commands: CanvasCreate
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
return (
|
||||
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-4" : variant === "workflow" ? "grid-cols-1" : "grid-cols-2")}>
|
||||
<div className={cn("grid gap-1", variant === "node" ? "grid-cols-5" : variant === "workflow" ? "grid-cols-1" : "grid-cols-2")}>
|
||||
{commands.map((command) => (
|
||||
<motion.button
|
||||
key={command.id}
|
||||
|
||||
@@ -414,6 +414,7 @@ function EmptyImageContent({ node, theme, isBatchRoot, batchCount, batchExpanded
|
||||
function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContentProps) {
|
||||
const playWhenReadyRef = useRef(false);
|
||||
const playerBoxRef = useRef<HTMLDivElement>(null);
|
||||
const { updateMetadata } = useCanvasNodeActions();
|
||||
const { url, loading, load } = useNodeResourceUrl(node, false);
|
||||
const subtitleEntries = node.metadata?.subtitleEntries || [];
|
||||
const subtitleStyle = node.metadata?.subtitleStyle || createDefaultSubtitleStyle();
|
||||
@@ -425,7 +426,11 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContent
|
||||
const video = box?.querySelector("video");
|
||||
if (!video) return;
|
||||
const handleLoadedMetadata = () => {
|
||||
if (video.videoWidth > 0 && video.videoHeight > 0) setVideoSize({ width: video.videoWidth, height: video.videoHeight });
|
||||
if (video.videoWidth <= 0 || video.videoHeight <= 0) return;
|
||||
setVideoSize({ width: video.videoWidth, height: video.videoHeight });
|
||||
if (node.metadata?.naturalWidth !== video.videoWidth || node.metadata?.naturalHeight !== video.videoHeight) {
|
||||
updateMetadata?.(node.id, { naturalWidth: video.videoWidth, naturalHeight: video.videoHeight });
|
||||
}
|
||||
};
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
handleLoadedMetadata();
|
||||
@@ -436,7 +441,7 @@ function VideoNodeContent({ node, theme, reduceMediaEffects }: CanvasNodeContent
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
};
|
||||
}, [subtitleEntries.length, url]);
|
||||
}, [node.id, node.metadata?.naturalHeight, node.metadata?.naturalWidth, subtitleEntries.length, updateMetadata, url]);
|
||||
|
||||
if (!node.metadata?.content) return <EmptyMediaContent icon={<Video className="size-7 opacity-35" />} label="空视频节点" color={theme.node.placeholder} />;
|
||||
if (!url) return <DeferredMediaLoad icon={loading ? <LoaderCircle className="size-5 animate-spin" /> : <Play className="size-5 fill-current" />} label={loading ? "正在缓存视频" : "加载并缓存视频"} disabled={loading} onClick={() => { playWhenReadyRef.current = true; void load(); }} />;
|
||||
|
||||
@@ -113,6 +113,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
const hasImageContent = data.type === CanvasNodeType.Image && Boolean(data.metadata?.content);
|
||||
const hasVideoContent = data.type === CanvasNodeType.Video && Boolean(data.metadata?.content);
|
||||
const hasAudioContent = data.type === CanvasNodeType.Audio && Boolean(data.metadata?.content);
|
||||
const mediaDimensionLabel = formatMediaDimensionLabel(data, hasImageContent || hasVideoContent);
|
||||
const isComposerNode = data.type === CanvasNodeType.Config;
|
||||
const hasMediaContent = hasImageContent || hasVideoContent || hasAudioContent;
|
||||
const isBatchRoot = data.type === CanvasNodeType.Image && Boolean(data.metadata?.isBatchRoot) && batchCount > 1;
|
||||
@@ -280,6 +281,7 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
<NodeExternalHeader
|
||||
node={data}
|
||||
scale={scale}
|
||||
dimensionLabel={mediaDimensionLabel}
|
||||
active={hovered || isSelected || isFocusRelated}
|
||||
editable={!readOnly && !data.metadata?.locked && Boolean(onTitleChange)}
|
||||
editing={isEditingTitle}
|
||||
@@ -601,9 +603,17 @@ function ResizeHandle({ corner, onMouseDown }: { corner: ResizeCorner; onMouseDo
|
||||
|
||||
const NODE_EXTERNAL_HEADER_MIN_SCALE = 0.35;
|
||||
|
||||
function NodeExternalHeader({ node, scale, active, editable, editing, draft, theme, onDraftChange, onEdit, onCommit, onCancel }: {
|
||||
function formatMediaDimensionLabel(node: CanvasNodeData, hasVisualMediaContent: boolean) {
|
||||
const width = node.metadata?.naturalWidth;
|
||||
const height = node.metadata?.naturalHeight;
|
||||
if (!hasVisualMediaContent || !Number.isFinite(width) || !Number.isFinite(height) || !width || !height || width <= 0 || height <= 0) return null;
|
||||
return `${Math.round(width)}*${Math.round(height)}`;
|
||||
}
|
||||
|
||||
function NodeExternalHeader({ node, scale, dimensionLabel, active, editable, editing, draft, theme, onDraftChange, onEdit, onCommit, onCancel }: {
|
||||
node: CanvasNodeData;
|
||||
scale: number;
|
||||
dimensionLabel: string | null;
|
||||
active: boolean;
|
||||
editable: boolean;
|
||||
editing: boolean;
|
||||
@@ -619,12 +629,14 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
const inverseScale = 1 / Math.max(scale, 0.05);
|
||||
const Icon = nodeTypeIcon(node.type);
|
||||
const maxHeaderWidth = Math.min(240, node.width * scale);
|
||||
const externalHeaderWidth = node.width * scale;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="canvas-node-external-header absolute bottom-full left-0 z-[var(--node-z-overlay)] flex h-6 items-center gap-1 overflow-hidden"
|
||||
style={{
|
||||
maxWidth: maxHeaderWidth,
|
||||
width: dimensionLabel ? externalHeaderWidth : undefined,
|
||||
maxWidth: dimensionLabel ? undefined : maxHeaderWidth,
|
||||
borderRadius: "var(--r-sm)",
|
||||
background: "transparent",
|
||||
paddingInline: "var(--space-1-half)",
|
||||
@@ -635,30 +647,33 @@ function NodeExternalHeader({ node, scale, active, editable, editing, draft, the
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" strokeWidth={1.8} />
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded bg-transparent px-1.5 text-xs font-medium outline-none"
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") event.currentTarget.blur();
|
||||
if (event.key === "Escape") onCancel();
|
||||
}}
|
||||
aria-label="节点名称"
|
||||
/>
|
||||
) : editable ? (
|
||||
<button type="button" className="group flex min-w-0 flex-1 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1" style={{ opacity: active ? 1 : 0.78, outlineColor: theme.node.muted }} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
|
||||
<span className="min-w-0 flex-1 truncate" title={node.title}>{node.title}</span>
|
||||
<Pencil className="size-2.5 shrink-0 opacity-55 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium" title={node.title} style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
|
||||
)}
|
||||
<div className="flex min-w-0 items-center gap-1" style={{ maxWidth: maxHeaderWidth }}>
|
||||
<Icon className="size-3 shrink-0" strokeWidth={1.8} />
|
||||
{editing ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={draft}
|
||||
className="h-6 min-w-20 max-w-[190px] flex-1 truncate rounded bg-transparent px-1.5 text-xs font-medium outline-none"
|
||||
style={{ background: "transparent", color: theme.node.text }}
|
||||
onChange={(event) => onDraftChange(event.target.value)}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
onBlur={onCommit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") event.currentTarget.blur();
|
||||
if (event.key === "Escape") onCancel();
|
||||
}}
|
||||
aria-label="节点名称"
|
||||
/>
|
||||
) : editable ? (
|
||||
<button type="button" className="group flex min-w-0 flex-1 items-center gap-1 rounded px-0.5 text-xs font-medium outline-none transition-opacity hover:opacity-100 focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-1" style={{ opacity: active ? 1 : 0.78, outlineColor: theme.node.muted }} onClick={onEdit} aria-label={`编辑节点名称:${node.title}`}>
|
||||
<span className="min-w-0 flex-1 truncate" title={node.title}>{node.title}</span>
|
||||
<Pencil className="size-2.5 shrink-0 opacity-55 transition-opacity group-hover:opacity-100" />
|
||||
</button>
|
||||
) : (
|
||||
<span className="min-w-0 flex-1 truncate text-xs font-medium" title={node.title} style={{ opacity: active ? 1 : 0.78 }}>{node.title}</span>
|
||||
)}
|
||||
</div>
|
||||
{dimensionLabel ? <span className="ml-auto shrink-0 whitespace-nowrap text-[var(--fs-micro)] font-medium leading-none tabular-nums" style={{ color: theme.node.muted }}>{dimensionLabel}</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -244,7 +244,7 @@ function AddNodeMenu({ x, theme, commands }: {
|
||||
commands: CanvasCreateCommand[];
|
||||
}) {
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[260px] max-w-[calc(100vw-24px)]" style={{ left: x || "50%", transformOrigin: "bottom center", x: "-50%" }}>
|
||||
<motion.div initial={{ opacity: 0, scaleY: 0.9, y: 8 }} animate={{ opacity: 1, scaleY: 1, y: 0 }} exit={{ opacity: 0, scaleY: 0.92, y: 6 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="pointer-events-auto absolute bottom-[var(--canvas-dock-popover-offset)] z-[var(--dock-z-popover)] w-[420px] max-w-[calc(100vw-24px)]" style={{ left: x || "50%", transformOrigin: "bottom center", x: "-50%" }}>
|
||||
<SpotlightSurface spotlightColor={theme.toolbar.itemHover} initial={{ opacity: 0, scale: 0.97 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0 } }} transition={{ duration: aceternityMotion.duration.instant, ease: aceternityMotion.easing.enter }} className="aceternity-floating-panel overflow-hidden rounded-[var(--panel-radius)] border p-2 backdrop-blur-2xl" style={{ background: theme.spatial.elevated, borderColor: theme.toolbar.border, color: theme.node.text }} onWheel={(event) => event.stopPropagation()}>
|
||||
<CanvasCreateMenu commands={commands} />
|
||||
</SpotlightSurface>
|
||||
|
||||
@@ -14,8 +14,8 @@ export const NODE_DEFAULT_SIZE = {
|
||||
[CanvasNodeType.Drawing]: { width: 440, height: 300, title: "绘图" },
|
||||
[CanvasNodeType.Script]: { width: 920, height: 360, title: "分镜脚本" },
|
||||
[CanvasNodeType.Skill]: { width: 360, height: 220, title: "技能" },
|
||||
// 配置节点同时承载模式、渠道、工作流和参数;预留稳定空间,避免视频工作流控件互相贴合。
|
||||
[CanvasNodeType.Config]: { width: 380, height: 340, title: "生成配置" },
|
||||
// 配置节点同时承载模式、渠道、工作流和参数;预留稳定空间,避免控件和错误状态互相挤压。
|
||||
[CanvasNodeType.Config]: { width: 480, height: 390, title: "生成配置" },
|
||||
[CanvasNodeType.Video]: { width: 720, height: 405, title: "视频" },
|
||||
[CanvasNodeType.Audio]: { width: 340, height: 120, title: "Audio" },
|
||||
[CanvasNodeType.Frame]: { width: 760, height: 520, title: "未命名背板" },
|
||||
|
||||
@@ -401,7 +401,9 @@ export function generationWorkflowMetadata(config: AiConfig): Pick<CanvasNodeMet
|
||||
|
||||
export function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefined, mode: CanvasNodeGenerationMode, requirements?: ModelRequirements): AiConfig {
|
||||
// 只有独立 Config 节点读取工作流元数据;普通图片/视频/音频节点始终按基础模型生成。
|
||||
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config ? resolveCanvasWorkflowProvider(node.metadata) : "model";
|
||||
const workflowProvider = mode !== "text" && node?.type === CanvasNodeType.Config
|
||||
? resolveCanvasWorkflowProvider(node.metadata) === "comfyui" ? "comfyui" : "runninghub"
|
||||
: "model";
|
||||
const defaultModel = mode === "image" ? config.imageModel : mode === "video" ? config.videoModel : mode === "audio" ? config.audioModel : config.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const storedModel = resolveCanvasGenerationModel(config, node?.metadata?.model, mode);
|
||||
@@ -542,12 +544,13 @@ export function supportsVideoReferenceAudio(config: AiConfig) {
|
||||
}
|
||||
|
||||
export function resetInterruptedGeneration(nodes: CanvasNodeData[]) {
|
||||
const configWidth = NODE_DEFAULT_SIZE[CanvasNodeType.Config].width;
|
||||
const configHeight = NODE_DEFAULT_SIZE[CanvasNodeType.Config].height;
|
||||
return nodes.map((node) => {
|
||||
const mediaNode = ensureMediaNodeMinimumSize(node);
|
||||
const resizedNode =
|
||||
mediaNode.type === CanvasNodeType.Config && mediaNode.height < configHeight
|
||||
? { ...mediaNode, height: configHeight }
|
||||
mediaNode.type === CanvasNodeType.Config && (mediaNode.width < configWidth || mediaNode.height < configHeight)
|
||||
? { ...mediaNode, width: Math.max(mediaNode.width, configWidth), height: Math.max(mediaNode.height, configHeight) }
|
||||
: mediaNode.type === CanvasNodeType.Script && mediaNode.height < NODE_DEFAULT_SIZE[CanvasNodeType.Script].height
|
||||
? { ...mediaNode, height: NODE_DEFAULT_SIZE[CanvasNodeType.Script].height }
|
||||
: mediaNode;
|
||||
|
||||
@@ -1,24 +1,14 @@
|
||||
import { Clapperboard, Folder, FolderOpen, Image as ImageIcon, Layers3, Music2, Palette, PanelTop, Pencil, Type, UploadCloud, UserRound, Video, Workflow } from "lucide-react";
|
||||
import { Folder, FolderOpen, Layers3, Palette, UploadCloud, UserRound, Workflow } from "lucide-react";
|
||||
|
||||
import { getNodeIcon, getNodeLabel } from "@/lib/canvas/node-registry";
|
||||
import { registerAddNodeMenuCommands, type AddNodeMenuCommand } from "@/lib/canvas/tool-registry";
|
||||
import { PortraitClearanceIcon } from "@/components/canvas/portrait-clearance/portrait-clearance-icon";
|
||||
import { PORTRAIT_CLEARANCE_NODE_TYPE, PORTRAIT_CLEARANCE_PLUGIN_ID } from "@/lib/portrait-clearance/contracts";
|
||||
import { CanvasNodeType, type CanvasNodeTypeId } from "@/types/canvas";
|
||||
import { CanvasNodeType } from "@/types/canvas";
|
||||
|
||||
/** 真正创建节点的命令,文案与图标统一取自节点注册表。 */
|
||||
function nodeCommand(type: CanvasNodeType, rest: Omit<AddNodeMenuCommand, "id" | "label" | "icon" | "section">): AddNodeMenuCommand {
|
||||
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "node", ...rest };
|
||||
}
|
||||
|
||||
/**
|
||||
* 扩展节点命令:落在独立的「展示与加工」分区,不挤占上方调好的四列创作网格。
|
||||
* 创建逻辑一律是 createNode(type),所以统一走 onAddExtensionNode 而不是各开一个 handler。
|
||||
*/
|
||||
function extensionCommand(type: CanvasNodeTypeId, defaultOrder: number): AddNodeMenuCommand {
|
||||
return { id: type, label: getNodeLabel(type), icon: getNodeIcon(type), section: "extension", defaultOrder, run: (ctx) => ctx.handlers.onAddExtensionNode(type) };
|
||||
}
|
||||
|
||||
export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
// 项目级动作不占用节点网格,保证 8 个节点稳定保持四列两行。
|
||||
{ id: "style", label: "项目画风", icon: <Palette />, section: "project", defaultOrder: 10, applicable: (ctx) => !ctx.isProjectLinked, run: (ctx) => ctx.handlers.onChooseStyle() },
|
||||
@@ -33,25 +23,8 @@ export const addNodeMenuCommands: AddNodeMenuCommand[] = [
|
||||
// 导演台落在节点分区,但它开的是导演工作台、不是某种画布节点,故不走注册表。
|
||||
{ id: "director", label: "导演台", icon: <Layers3 />, badge: "3D", section: "node", defaultOrder: 70, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onOpenDirector() },
|
||||
nodeCommand(CanvasNodeType.Audio, { defaultOrder: 80, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddAudio() }),
|
||||
// 展示与加工(扩展节点)
|
||||
extensionCommand(CanvasNodeType.Markdown, 10),
|
||||
extensionCommand(CanvasNodeType.Svg, 20),
|
||||
extensionCommand(CanvasNodeType.Html, 30),
|
||||
extensionCommand(CanvasNodeType.Panorama, 40),
|
||||
extensionCommand(CanvasNodeType.Compare, 50),
|
||||
extensionCommand(CanvasNodeType.Chart, 60),
|
||||
extensionCommand(CanvasNodeType.ColorGrade, 70),
|
||||
// 云端和本地工作流共用独立配置节点,不进入基础模型节点的渠道选择。
|
||||
{ id: "workflow", label: "工作流", icon: <Workflow />, section: "workflow", defaultOrder: 10, applicable: (ctx) => ctx.workspaceMode !== "simple", run: (ctx) => ctx.handlers.onAddWorkflow() },
|
||||
{
|
||||
id: PORTRAIT_CLEARANCE_NODE_TYPE,
|
||||
label: getNodeLabel(PORTRAIT_CLEARANCE_NODE_TYPE),
|
||||
icon: <PortraitClearanceIcon />,
|
||||
section: "extension",
|
||||
defaultOrder: 80,
|
||||
applicable: (ctx) => Boolean(ctx.enabledPluginIds?.has(PORTRAIT_CLEARANCE_PLUGIN_ID)),
|
||||
run: (ctx) => ctx.handlers.onAddExtensionNode(PORTRAIT_CLEARANCE_NODE_TYPE),
|
||||
},
|
||||
// 导入资源
|
||||
{ id: "upload", label: "上传文件", icon: <UploadCloud />, section: "resource", defaultOrder: 10, run: (ctx) => ctx.handlers.onUpload() },
|
||||
{ id: "project-character", label: "添加角色卡", icon: <UserRound />, section: "resource", defaultOrder: 20, applicable: (ctx) => ctx.isProjectLinked, run: (ctx) => ctx.handlers.onOpenProjectCharacters() },
|
||||
|
||||
@@ -185,7 +185,7 @@ export type AddNodeMenuCommand = {
|
||||
icon: ReactNode;
|
||||
badge?: string;
|
||||
// extension:展示与加工类扩展节点。单独一区,避免挤散 node 区调好的四列网格。
|
||||
section: "node" | "extension" | "workflow" | "project" | "resource";
|
||||
section: "node" | "workflow" | "project" | "resource";
|
||||
defaultOrder: number;
|
||||
applicable?: (ctx: AddNodeMenuContext) => boolean;
|
||||
run: (ctx: AddNodeMenuContext) => void;
|
||||
|
||||
@@ -510,7 +510,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
) : null}
|
||||
|
||||
{modelCapability === "text" || modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-references">
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-references">
|
||||
<SectionHeading title="引用与限制" description="按媒体类型纵向配置数量、大小、时长及通用约束。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
@@ -526,7 +526,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
) : null}
|
||||
|
||||
{modelCapability === "image" || modelCapability === "video" ? (
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-parameters">
|
||||
<section className="admin-form-section admin-model-editor-section admin-model-editor-section-stacked admin-model-editor-parameters">
|
||||
<SectionHeading title="协议参数" description="配置可发送参数、支持值与默认值;仅影响当前模型。" />
|
||||
<div className="admin-model-editor-section-content">
|
||||
<ModelCapabilityEditor
|
||||
|
||||
@@ -131,13 +131,13 @@ export function useCanvasNodeOperations({
|
||||
return;
|
||||
}
|
||||
const selectedWorkflowProvider = type === CanvasNodeType.Config
|
||||
? workflowProvider || "model"
|
||||
? workflowProvider || "runninghub"
|
||||
: undefined;
|
||||
const workflowTitle = type === CanvasNodeType.Config && selectedWorkflowProvider === "runninghub" ? "RunningHub 工作流" : type === CanvasNodeType.Config && selectedWorkflowProvider === "comfyui" ? "ComfyUI Bridge" : undefined;
|
||||
const metadata: CanvasNodeMetadata | undefined = type === CanvasNodeType.Drawing
|
||||
? { drawingEngine: defaultDrawingEngine }
|
||||
: type === CanvasNodeType.Config
|
||||
? { generationMode: "image", workflowProvider: selectedWorkflowProvider || "model" }
|
||||
? { generationMode: "image", workflowProvider: selectedWorkflowProvider }
|
||||
: type === PORTRAIT_CLEARANCE_NODE_TYPE
|
||||
? { portraitClearance: createDefaultPortraitClearanceState() }
|
||||
: undefined;
|
||||
|
||||
@@ -10374,22 +10374,22 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
.admin-secondary-drawer,
|
||||
.admin-modal-root,
|
||||
.admin-channel-modal-root {
|
||||
--admin-layer-0: #eef1f4;
|
||||
--admin-layer-1: #ffffff;
|
||||
--admin-layer-2: #f5f7f9;
|
||||
--admin-sidebar: #e9ebef;
|
||||
--admin-text-primary: #1f2328;
|
||||
--admin-text-secondary: #3f4854;
|
||||
--admin-text-muted: #5d6878;
|
||||
--admin-text-placeholder: #6f7b8b;
|
||||
--admin-control-border: color-mix(in srgb, var(--foreground) 27%, transparent);
|
||||
--admin-divider: color-mix(in srgb, var(--foreground) 16%, transparent);
|
||||
--admin-card-border: color-mix(in srgb, var(--foreground) 15%, transparent);
|
||||
--admin-card-shadow: 0 12px 30px color-mix(in srgb, #1f2937 8%, transparent);
|
||||
--admin-layer-0: var(--workspace-content);
|
||||
--admin-layer-1: var(--workspace-surface);
|
||||
--admin-layer-2: color-mix(in srgb, var(--foreground) 4%, var(--workspace-content));
|
||||
--admin-sidebar: var(--workspace-navigation);
|
||||
--admin-text-primary: var(--foreground);
|
||||
--admin-text-secondary: color-mix(in srgb, var(--foreground) 72%, transparent);
|
||||
--admin-text-muted: color-mix(in srgb, var(--foreground) 52%, transparent);
|
||||
--admin-text-placeholder: color-mix(in srgb, var(--foreground) 42%, transparent);
|
||||
--admin-control-border: color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
--admin-divider: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
--admin-card-border: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
--admin-card-shadow: none;
|
||||
--admin-status-success: oklch(0.52 0.14 155);
|
||||
--admin-status-warning: oklch(0.62 0.14 78);
|
||||
--admin-status-error: var(--destructive);
|
||||
--admin-status-info: oklch(0.54 0.13 250);
|
||||
--admin-status-info: var(--foreground);
|
||||
--admin-chart-primary: var(--workspace-accent);
|
||||
--admin-chart-secondary: oklch(0.55 0.11 180);
|
||||
--admin-chart-warning: oklch(0.64 0.14 78);
|
||||
@@ -10400,21 +10400,21 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
.dark .admin-secondary-drawer,
|
||||
.dark .admin-modal-root,
|
||||
.dark .admin-channel-modal-root {
|
||||
--admin-layer-0: #0b0d10;
|
||||
--admin-layer-1: #14171b;
|
||||
--admin-layer-2: #1b1f25;
|
||||
--admin-sidebar: #0f1216;
|
||||
--admin-text-primary: #f2f4f7;
|
||||
--admin-text-secondary: #c3c8d0;
|
||||
--admin-text-muted: #99a2ae;
|
||||
--admin-text-placeholder: #858f9d;
|
||||
--admin-control-border: color-mix(in srgb, var(--foreground) 24%, transparent);
|
||||
--admin-divider: color-mix(in srgb, var(--foreground) 16%, transparent);
|
||||
--admin-card-border: color-mix(in srgb, var(--foreground) 13%, transparent);
|
||||
--admin-card-shadow: 0 16px 34px rgb(0 0 0 / 0.2);
|
||||
--admin-layer-0: var(--workspace-content);
|
||||
--admin-layer-1: var(--workspace-surface);
|
||||
--admin-layer-2: color-mix(in srgb, var(--foreground) 5%, var(--workspace-content));
|
||||
--admin-sidebar: var(--workspace-navigation);
|
||||
--admin-text-primary: var(--foreground);
|
||||
--admin-text-secondary: color-mix(in srgb, var(--foreground) 74%, transparent);
|
||||
--admin-text-muted: color-mix(in srgb, var(--foreground) 54%, transparent);
|
||||
--admin-text-placeholder: color-mix(in srgb, var(--foreground) 43%, transparent);
|
||||
--admin-control-border: color-mix(in srgb, var(--foreground) 18%, transparent);
|
||||
--admin-divider: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
--admin-card-border: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
--admin-card-shadow: none;
|
||||
--admin-status-success: oklch(0.72 0.14 155);
|
||||
--admin-status-warning: oklch(0.78 0.14 78);
|
||||
--admin-status-info: oklch(0.76 0.13 250);
|
||||
--admin-status-info: var(--foreground);
|
||||
--admin-chart-secondary: oklch(0.72 0.11 180);
|
||||
--admin-chart-warning: oklch(0.78 0.14 78);
|
||||
}
|
||||
@@ -10428,7 +10428,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
color: color-mix(in srgb, var(--admin-text-secondary) 84%, transparent) !important;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
letter-spacing: 0;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
@@ -10453,14 +10453,14 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
}
|
||||
|
||||
.admin-shell .app-workspace-nav-link.is-active {
|
||||
background: color-mix(in srgb, var(--admin-status-info) 11%, var(--admin-layer-2));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--admin-status-info) 16%, transparent);
|
||||
background: var(--workspace-nav-active-bg);
|
||||
box-shadow: none;
|
||||
color: var(--admin-text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-shell .app-workspace-nav-link.is-active::before {
|
||||
display: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.admin-shell .app-workspace-nav-link:hover:not(.is-active) {
|
||||
@@ -10476,7 +10476,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
}
|
||||
|
||||
.admin-shell .app-workspace-nav-link.is-active > svg {
|
||||
color: var(--admin-status-info);
|
||||
color: var(--admin-text-primary);
|
||||
}
|
||||
|
||||
.admin-shell .app-workspace-nav-link:focus-visible,
|
||||
@@ -10490,14 +10490,14 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
.admin-shell .admin-sidebar {
|
||||
background: var(--admin-sidebar);
|
||||
border-radius: 0 !important;
|
||||
border-right: 1px solid var(--admin-divider);
|
||||
border-right: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* 管理后台侧栏不复用通用 Tailwind 宽度:通用 sidebar 规则在后面会覆盖 w-*,导致视觉收缩但布局仍占展开宽度。 */
|
||||
.admin-shell .admin-sidebar {
|
||||
width: 224px;
|
||||
flex-basis: 224px;
|
||||
width: var(--workspace-sidebar-width);
|
||||
flex-basis: var(--workspace-sidebar-width);
|
||||
}
|
||||
|
||||
.admin-shell .admin-sidebar.is-collapsed {
|
||||
@@ -10507,8 +10507,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
|
||||
.admin-shell .admin-sidebar-header {
|
||||
height: 68px;
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
background: linear-gradient(160deg, color-mix(in srgb, var(--foreground) 3%, var(--admin-sidebar)), var(--admin-sidebar));
|
||||
border-bottom: 0;
|
||||
background: var(--admin-sidebar);
|
||||
}
|
||||
|
||||
.admin-shell .admin-sidebar-brand-mark {
|
||||
@@ -10528,7 +10528,7 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
margin-top: 1px;
|
||||
color: var(--admin-text-muted);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
letter-spacing: 0;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
@@ -10566,8 +10566,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
}
|
||||
|
||||
.admin-shell .admin-sidebar-footer {
|
||||
border-top: 1px solid var(--admin-divider);
|
||||
background: linear-gradient(0deg, color-mix(in srgb, var(--foreground) 2%, var(--admin-sidebar)), var(--admin-sidebar));
|
||||
border-top: 0;
|
||||
background: var(--admin-sidebar);
|
||||
padding: 9px 10px 10px;
|
||||
}
|
||||
|
||||
@@ -10634,14 +10634,14 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
|
||||
.admin-page-header {
|
||||
min-height: 58px;
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.admin-page-title {
|
||||
color: var(--admin-text-primary);
|
||||
font-size: 20px;
|
||||
letter-spacing: -0.02em;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -11100,8 +11100,8 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
.admin-settings-section {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--admin-card-border);
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
border-radius: var(--r-lg);
|
||||
background: var(--admin-layer-1);
|
||||
box-shadow: var(--admin-card-shadow);
|
||||
}
|
||||
@@ -11111,16 +11111,16 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
}
|
||||
|
||||
.admin-settings-section-summary {
|
||||
background: linear-gradient(145deg, color-mix(in srgb, var(--foreground) 4%, var(--admin-layer-1)), var(--admin-layer-1) 72%);
|
||||
background: var(--admin-layer-1);
|
||||
}
|
||||
|
||||
.admin-settings-section-content {
|
||||
background: color-mix(in srgb, var(--admin-layer-1) 96%, var(--admin-layer-2));
|
||||
background: var(--admin-layer-1);
|
||||
}
|
||||
|
||||
.admin-settings-section-footer {
|
||||
border-top: 1px solid var(--admin-divider);
|
||||
background: color-mix(in srgb, var(--admin-layer-2) 56%, var(--admin-layer-1));
|
||||
border-top: 0;
|
||||
background: var(--admin-layer-2);
|
||||
}
|
||||
|
||||
.admin-operation-card {
|
||||
@@ -11241,6 +11241,11 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
padding: 20px 22px;
|
||||
}
|
||||
|
||||
.admin-model-editor-section-stacked {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.admin-model-editor-section-heading,
|
||||
.admin-model-editor-section-content {
|
||||
min-width: 0;
|
||||
@@ -11880,19 +11885,34 @@ html:not(.dark) .creation-media-preview-modal .ant-modal-container { background:
|
||||
|
||||
.admin-page-frame-scrollable > :not(.admin-page-header) {
|
||||
width: 100%;
|
||||
max-width: 1120px;
|
||||
margin-inline: auto;
|
||||
max-width: none;
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.admin-settings-section:not(.is-stacked) .admin-settings-section-content {
|
||||
border-left: 1px solid var(--admin-divider);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.admin-settings-section.is-stacked .admin-settings-section-summary {
|
||||
border-bottom: 1px solid var(--admin-divider);
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.canvas-config-prompt-button {
|
||||
border: 0;
|
||||
transition: background-color var(--motion-instant) ease-out, transform var(--motion-instant) ease-out;
|
||||
}
|
||||
|
||||
.canvas-config-prompt-button:hover {
|
||||
filter: brightness(1.04);
|
||||
}
|
||||
|
||||
.canvas-config-prompt-button:focus-visible,
|
||||
.canvas-config-composer-editor:focus-within {
|
||||
outline: 1px solid color-mix(in srgb, currentColor 24%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.admin-shell .admin-monospace {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
font-size: 12px;
|
||||
|
||||
Reference in New Issue
Block a user