feat(canvas): add independent text generation count and streamline toolbar interactions
This commit is contained in:
@@ -107,7 +107,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
) : 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))} />
|
||||
) : (
|
||||
<CanvasTextSettingsPopover config={config} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
|
||||
<CanvasTextSettingsPopover config={config} count={node.metadata?.textCount || 1} placement="topRight" buttonClassName="canvas-compact-control !h-10 !w-full !justify-start !rounded-lg !px-2" onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} onCountChange={(textCount) => onConfigChange(node.id, { textCount })} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { CanvasNodeData } from "@/types/canvas";
|
||||
import i18n from "@/i18n";
|
||||
|
||||
export type ImageNodeActionToolId = "copyPrompt" | "reversePrompt" | "replace" | "resize" | "maskEdit" | "crop" | "split" | "upscale" | "superResolve" | "angle" | "view";
|
||||
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | "edit" | ImageNodeActionToolId;
|
||||
export type ImageQuickToolId = "info" | "delete" | "saveAsset" | "download" | ImageNodeActionToolId;
|
||||
|
||||
export type ImageToolHandlers = {
|
||||
onUpload: (node: CanvasNodeData) => void;
|
||||
@@ -36,9 +36,9 @@ export type ImageQuickToolsConfig = {
|
||||
showLabels: boolean;
|
||||
};
|
||||
|
||||
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v6";
|
||||
export const IMAGE_QUICK_TOOLS_STORAGE_KEY = "canvas-image-quick-tools-v7";
|
||||
|
||||
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download", "edit"];
|
||||
const defaultBaseToolIds: ImageQuickToolId[] = ["info", "delete", "saveAsset", "download"];
|
||||
|
||||
export const imageToolDefinitions: ImageToolDefinition[] = [
|
||||
{
|
||||
@@ -152,12 +152,12 @@ export function normalizeImageQuickToolIds(value: unknown[]) {
|
||||
}
|
||||
|
||||
export function readImageQuickToolsConfig(value: unknown): ImageQuickToolsConfig {
|
||||
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: true };
|
||||
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: true };
|
||||
if (Array.isArray(value)) return { ids: normalizeImageQuickToolIds(value), showLabels: false };
|
||||
if (!value || typeof value !== "object") return { ids: defaultImageQuickToolIds, showLabels: false };
|
||||
const data = value as Partial<ImageQuickToolsConfig>;
|
||||
return {
|
||||
ids: Array.isArray(data.ids) ? normalizeImageQuickToolIds(data.ids) : defaultImageQuickToolIds,
|
||||
showLabels: data.showLabels !== false,
|
||||
showLabels: data.showLabels === true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { App, Modal, Segmented, Tooltip } from "antd";
|
||||
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Pencil, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
import { Download, Ellipsis, FolderPlus, Image as ImageIcon, Info, MessageSquare, Minus, Music2, Plus, RefreshCw, Settings2, Trash2, Upload, Video } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
@@ -19,7 +19,6 @@ type CanvasNodeHoverToolbarProps = {
|
||||
onKeep: (nodeId: string) => void;
|
||||
onLeave: () => void;
|
||||
onInfo: (node: CanvasNodeData) => void;
|
||||
onEditText: (node: CanvasNodeData) => void;
|
||||
onDecreaseFont: (node: CanvasNodeData) => void;
|
||||
onIncreaseFont: (node: CanvasNodeData) => void;
|
||||
onToggleDialog: (node: CanvasNodeData) => void;
|
||||
@@ -57,7 +56,6 @@ export function CanvasNodeHoverToolbar({
|
||||
onKeep,
|
||||
onLeave,
|
||||
onInfo,
|
||||
onEditText,
|
||||
onDecreaseFont,
|
||||
onIncreaseFont,
|
||||
onToggleDialog,
|
||||
@@ -79,9 +77,9 @@ export function CanvasNodeHoverToolbar({
|
||||
extraTools = [],
|
||||
}: CanvasNodeHoverToolbarProps) {
|
||||
const [quickImageToolIds, setQuickImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [showImageToolLabels, setShowImageToolLabels] = useState(true);
|
||||
const [showImageToolLabels, setShowImageToolLabels] = useState(false);
|
||||
const [draftImageToolIds, setDraftImageToolIds] = useState<ImageQuickToolId[]>(defaultImageQuickToolIds);
|
||||
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(true);
|
||||
const [draftShowImageToolLabels, setDraftShowImageToolLabels] = useState(false);
|
||||
const [imageToolSettingsOpen, setImageToolSettingsOpen] = useState(false);
|
||||
const { message } = App.useApp();
|
||||
const { t } = useTranslation();
|
||||
@@ -117,7 +115,6 @@ export function CanvasNodeHoverToolbar({
|
||||
const hasAudio = isAudio && Boolean(node.metadata?.content);
|
||||
const isText = node.type === CanvasNodeType.Text;
|
||||
const isConfig = node.type === CanvasNodeType.Config;
|
||||
const canOpenDialog = isText || hasImage || isVideo;
|
||||
const canRetry = node.metadata?.status === "error";
|
||||
const quickImageToolIdSet = new Set(quickImageToolIds);
|
||||
const copyImagePrompt = (target: CanvasNodeData) => {
|
||||
@@ -145,8 +142,7 @@ export function CanvasNodeHoverToolbar({
|
||||
...(canRetry ? [{ id: "retry", title: t("canvas.nodeToolbar.retryTitle"), label: t("canvas.node.retry"), icon: <RefreshCw className="size-4" />, onClick: () => onRetry(node) }] : []),
|
||||
...(hasImage || hasVideo || isText ? [{ id: "saveAsset", title: t("common.addToAssets"), label: t("canvas.nodeToolbar.saveAsset"), icon: <FolderPlus className="size-4" />, onClick: () => onSaveAsset(node) }] : []),
|
||||
...(hasImage || hasVideo || hasAudio ? [{ id: "download", title: t(hasAudio ? "canvas.nodeToolbar.downloadAudio" : hasVideo ? "canvas.nodeToolbar.downloadVideo" : "canvas.nodeToolbar.downloadImage"), label: t("common.download"), icon: <Download className="size-4" />, onClick: () => onDownload(node) }] : []),
|
||||
...(canOpenDialog ? [{ id: "edit", title: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "editText", title: t("canvas.nodeToolbar.editTextTitle"), label: t("canvas.nodeToolbar.editText"), icon: <Pencil className="size-4" />, onClick: () => onEditText(node) }] : []),
|
||||
...(isVideo ? [{ id: "edit", title: t("common.edit"), label: t("common.edit"), icon: <MessageSquare className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "generateImage", title: t("canvas.node.generateImage"), label: t("canvas.node.generate"), icon: <ImageIcon className="size-4" />, onClick: () => onGenerateImage(node) }] : []),
|
||||
...(isConfig ? [{ id: "config", title: t("canvas.configNode.title"), label: t("canvas.configNode.title"), icon: <Settings2 className="size-4" />, onClick: () => onToggleDialog(node) }] : []),
|
||||
...(isText ? [{ id: "decreaseFont", title: t("canvas.nodeToolbar.decreaseFont"), label: t("canvas.nodeToolbar.zoomOut"), icon: <Minus className="size-4" />, onClick: () => onDecreaseFont(node) }] : []),
|
||||
@@ -194,7 +190,7 @@ export function CanvasNodeHoverToolbar({
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
{toolbarTools.map((tool) => (
|
||||
<ToolbarAction key={tool.id} {...tool} showLabel={showImageToolLabels} />
|
||||
<ToolbarAction key={tool.id} {...tool} showLabel={isImage ? showImageToolLabels : true} />
|
||||
))}
|
||||
{hasImage ? <ToolbarAction id="more" title={t("canvas.imageTools.configure")} label={t("canvas.imageTools.more")} icon={<Ellipsis className="size-4" />} active={imageToolSettingsOpen} onClick={openImageToolSettings} showLabel={showImageToolLabels} /> : null}
|
||||
</div>
|
||||
|
||||
@@ -115,7 +115,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
) : (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} className="max-w-[190px]" />
|
||||
<CanvasTextSettingsPopover config={config} onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} />
|
||||
<CanvasTextSettingsPopover config={config} count={node.metadata?.textCount || 1} onConfigChange={(_, value) => onConfigChange(node.id, { reasoningEffort: value })} onCountChange={(textCount) => onConfigChange(node.id, { textCount })} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,7 +24,6 @@ type CanvasNodeProps = {
|
||||
isFocusRelated: boolean;
|
||||
isConnectionTarget: boolean;
|
||||
isConnecting: boolean;
|
||||
editRequestNonce?: number;
|
||||
showPanel: boolean;
|
||||
showImageInfo: boolean;
|
||||
mentionReferences?: CanvasResourceReference[];
|
||||
@@ -90,7 +89,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
isFocusRelated,
|
||||
isConnectionTarget,
|
||||
isConnecting,
|
||||
editRequestNonce = 0,
|
||||
showPanel,
|
||||
showImageInfo,
|
||||
mentionReferences = [],
|
||||
@@ -203,11 +201,6 @@ export const CanvasNode = React.memo(function CanvasNode({
|
||||
textarea?.setSelectionRange(textarea.value.length, textarea.value.length);
|
||||
}, [isEditingContent]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editRequestNonce || data.type !== CanvasNodeType.Text) return;
|
||||
setIsEditingContent(true);
|
||||
}, [data.type, editRequestNonce]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditingContent) return;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { Button } from "antd";
|
||||
import { Button, InputNumber } from "antd";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { reasoningEffortLabel, TextSettingsPanel } from "@/components/text-settings-panel";
|
||||
@@ -12,11 +12,13 @@ import type { AiConfig, ReasoningEffort } from "@/stores/use-config-store";
|
||||
type CanvasTextSettingsPopoverProps = {
|
||||
config: AiConfig;
|
||||
onConfigChange: (key: "reasoningEffort", value: ReasoningEffort) => void;
|
||||
count?: number;
|
||||
onCountChange?: (count: number) => void;
|
||||
buttonClassName?: string;
|
||||
placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight";
|
||||
};
|
||||
|
||||
export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
|
||||
export function CanvasTextSettingsPopover({ config, onConfigChange, count, onCountChange, buttonClassName, placement = "topLeft" }: CanvasTextSettingsPopoverProps) {
|
||||
const { t } = useTranslation();
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const buttonRef = useRef<HTMLSpanElement>(null);
|
||||
@@ -43,13 +45,13 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const panel = open && buttonRect ? <TextSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} onConfigChange={onConfigChange} /> : null;
|
||||
const panel = open && buttonRect ? <TextSettingsPortal buttonRect={buttonRect} panelRef={panelRef} placement={placement} theme={theme} config={config} count={count} onConfigChange={onConfigChange} onCountChange={onCountChange} /> : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<span ref={buttonRef} className="inline-flex min-w-0">
|
||||
<Button size="small" type="text" className={buttonClassName || "!h-8 !max-w-[170px] !justify-start !rounded-full !px-2.5"} style={{ background: theme.node.fill, color: theme.node.text }} icon={<Settings2 className="size-3.5" />} onClick={() => setOpen((current) => !current)}>
|
||||
<span className="truncate">{t("canvas.controls.reasoning")} · {reasoningEffortLabel(config.reasoningEffort)}</span>
|
||||
<span className="truncate">{t("canvas.controls.reasoning")} · {reasoningEffortLabel(config.reasoningEffort)}{onCountChange ? ` · ${t("canvas.controls.generations", { count })}` : ""}</span>
|
||||
</Button>
|
||||
</span>
|
||||
{panel}
|
||||
@@ -57,14 +59,17 @@ export function CanvasTextSettingsPopover({ config, onConfigChange, buttonClassN
|
||||
);
|
||||
}
|
||||
|
||||
function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, onConfigChange }: {
|
||||
function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, count, onConfigChange, onCountChange }: {
|
||||
buttonRect: DOMRect;
|
||||
panelRef: RefObject<HTMLDivElement | null>;
|
||||
placement: CanvasTextSettingsPopoverProps["placement"];
|
||||
theme: (typeof canvasThemes)[keyof typeof canvasThemes];
|
||||
config: AiConfig;
|
||||
count?: number;
|
||||
onConfigChange: CanvasTextSettingsPopoverProps["onConfigChange"];
|
||||
onCountChange?: (count: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const width = 356;
|
||||
const gap = 8;
|
||||
const margin = 12;
|
||||
@@ -88,6 +93,12 @@ function TextSettingsPortal({ buttonRect, panelRef, placement, theme, config, on
|
||||
return createPortal(
|
||||
<div ref={panelRef} style={style} onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}>
|
||||
<TextSettingsPanel config={config} onConfigChange={onConfigChange} theme={theme} />
|
||||
{onCountChange ? (
|
||||
<div className="mt-4 space-y-2.5">
|
||||
<div className="text-sm font-medium" style={{ color: theme.node.muted }}>{t("settingsPanels.text.count")}</div>
|
||||
<InputNumber className="w-full" min={1} max={15} precision={0} value={count} onChange={(value) => onCountChange(value || 1)} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
image: { title: "Image settings", quality: "Quality", size: "Size", align16: "Align to multiples of 16", align16Hint: "Round dimensions up to the next multiple of 16 after input", aspectRatio: "Aspect ratio", transparent: "Transparent background", transparentHint: "Generate an image without a background when supported by the model", count: "Image count", images: "{{count}} images" },
|
||||
video: { title: "Video settings", quality: "Quality", size: "Size", seconds: "Seconds", resolution: "Resolution", ratio: "Aspect ratio", duration: "Duration", smart: "Smart", output: "Output", generateAudio: "Generate audio", watermark: "Add watermark", adaptive: "Adaptive", sizes: { landscape: "Landscape", portrait: "Portrait", square: "Square", widescreen: "Widescreen", tall: "Tall", auto: "Auto" }, ratios: { landscape: "Landscape", portrait: "Portrait", square: "Square", standardLandscape: "Standard landscape", standardPortrait: "Standard portrait", cinematic: "Cinematic", adaptive: "Adaptive" } },
|
||||
audio: { title: "Audio settings", voice: "Voice", format: "Format", speed: "Speed", instructions: "Voice instructions", instructionsPlaceholder: "For example: natural, warm, and suitable for narration." },
|
||||
text: { title: "Text settings", reasoning: "Reasoning effort" },
|
||||
text: { title: "Text settings", reasoning: "Reasoning effort", count: "Generation count" },
|
||||
model: { select: "Select model", assign: "Assign a model for {{capability}} in the provider settings", noMatch: "No matching {{capability}} models", addFirst: "Add a provider and models in Settings first", capabilities: { image: "image generation", video: "video", text: "text", audio: "audio" } },
|
||||
},
|
||||
generation: { pending: ["Creating image", "Almost there", "Just a little longer", "Refining details"] },
|
||||
@@ -283,7 +283,7 @@ export default {
|
||||
plugins: { title: "Node plugins", installedPlugin: "Installed plugin {{name}}", installed: "Installed {{name}}", installFailed: "Installation failed: {{error}}", enabled: "Enabled", disabled: "Disabled", upgradeAvailable: "New version available — click to upgrade", updateFromSource: "Update from source", updated: "Updated", uninstallTitle: "Uninstall this plugin?", uninstall: "Uninstall", newVersion: "A new version is available", officialDescription: "Official plugins from this project's registry", refresh: "Refresh", loadFailed: "Failed to load: {{error}}", loadingOfficial: "Loading official plugins…", noOfficial: "No official plugins", install: "Install", urlPlaceholder: "Enter a plugin JavaScript URL, for example https://.../plugin.js", noThirdParty: "No third-party plugins installed", official: "Official", local: "Local", thirdParty: "Third-party", warning: "Plugin code runs directly on this page and can access local data, including your AI API key. Only install plugins from sources you trust.", aiConfigRequired: "AI configuration is not ready. Configure a model and API key in Settings first.", interactiveTitle: "Interaction mode is active. Click to switch to Move mode and drag the node.", movableTitle: "Move mode is active. Click to switch to Interaction mode and operate the node content.", move: "Move", interact: "Interact" },
|
||||
promptPanel: { video: "Describe the video you want to generate", audio: "Describe the audio you want to generate", image: "Describe the image you want to generate", text: "Describe the text you want to generate", editImage: "Describe how you want to change this image", editText: "Describe how you want to revise this text", expandEditor: "Expand editor", editorTitle: "Edit prompt", stopGeneration: "Stop generation", generate: "Generate", stop: "Stop" },
|
||||
composer: { title: "Compose prompt", description: "Use @ to reference connected assets; references are renumbered before sending", placeholder: "Enter a prompt and use @ to reference connected images or text", imagePreview: "Referenced image preview", resources: { image: "Image {{index}}", video: "Video {{index}}", audio: "Audio {{index}}", text: "Text {{index}}" } },
|
||||
controls: { ratio: "Ratio", duplicate: "Duplicate", delete: "Delete", images: "{{count}} images", reasoning: "Reasoning" },
|
||||
controls: { ratio: "Ratio", duplicate: "Duplicate", delete: "Delete", images: "{{count}} images", generations: "{{count}} runs", reasoning: "Reasoning" },
|
||||
generation: { interrupted: "Generation was interrupted by a page refresh. Generate again.", front: "front view", rotateRight: "rotated {{angle}} degrees right", rotateLeft: "rotated {{angle}} degrees left", level: "eye-level view", topDown: "{{angle}}-degree top-down view", lowAngle: "{{angle}}-degree low-angle view", angleLabel: "AI multi-angle: {{horizontal}}, {{pitch}}, camera distance {{distance}}, {{lens}} lens", anglePrompt: "Regenerate a new view of the same subject from the reference image. Preserve the subject, colors, materials, and visual style; do not merely apply perspective distortion. {{angle}}." },
|
||||
agentOps: { add_node: "Add node", update_node: "Update node", delete_node: "Delete node", delete_connections: "Delete connections", connect_nodes: "Connect", set_viewport: "Adjust view", select_nodes: "Select nodes", run_generation: "Run generation" },
|
||||
pluginErrors: { invalidExport: "The plugin did not export a valid object", missingFields: "The plugin is missing id or nodes", downloadFailed: "Download failed (HTTP {{status}})", registryFailed: "Failed to load the official plugin registry (HTTP {{status}})" },
|
||||
|
||||
@@ -33,7 +33,7 @@ export default {
|
||||
image: { title: "图像设置", quality: "质量", size: "尺寸", align16: "16 倍数对齐", align16Hint: "输入完成后自动向上补成 16 的倍数", aspectRatio: "宽高比", transparent: "透明背景", transparentHint: "开启后生成无背景的透明图像(仅部分模型可用)", count: "生成张数", images: "{{count}} 张" },
|
||||
video: { title: "视频设置", quality: "清晰度", size: "尺寸", seconds: "秒数", resolution: "分辨率", ratio: "比例", duration: "时长", smart: "智能", output: "输出", generateAudio: "生成声音", watermark: "添加水印", adaptive: "自适应", sizes: { landscape: "横屏", portrait: "竖屏", square: "方形", widescreen: "宽屏", tall: "长图", auto: "自动" }, ratios: { landscape: "横屏", portrait: "竖屏", square: "方形", standardLandscape: "标准横屏", standardPortrait: "标准竖屏", cinematic: "宽银幕", adaptive: "自适应" } },
|
||||
audio: { title: "音频设置", voice: "声音", format: "格式", speed: "语速", instructions: "声音指令", instructionsPlaceholder: "例如:自然、温暖、适合旁白。" },
|
||||
text: { title: "文本设置", reasoning: "推理强度" },
|
||||
text: { title: "文本设置", reasoning: "推理强度", count: "生成次数" },
|
||||
model: { select: "选择模型", assign: "请先在渠道里为{{capability}}指定模型", noMatch: "暂无匹配的{{capability}}模型", addFirst: "请先到配置里添加渠道和模型", capabilities: { image: "生图", video: "视频", text: "文本", audio: "音频" } },
|
||||
},
|
||||
generation: { pending: ["正在创建图片", "马上就好了", "再等等", "正在整理细节"] },
|
||||
@@ -283,7 +283,7 @@ export default {
|
||||
plugins: { title: "节点插件", installedPlugin: "已安装插件 {{name}}", installed: "已安装 {{name}}", installFailed: "安装失败:{{error}}", enabled: "已启用", disabled: "已禁用", upgradeAvailable: "有新版本,点击升级", updateFromSource: "从来源更新", updated: "已更新", uninstallTitle: "卸载该插件?", uninstall: "卸载", newVersion: "有新版本可升级", officialDescription: "本项目官方插件,来自仓库注册表", refresh: "刷新", loadFailed: "加载失败:{{error}}", loadingOfficial: "正在获取官方插件…", noOfficial: "暂无官方插件", install: "安装", urlPlaceholder: "输入插件 JS 文件 URL,例如 https://.../plugin.js", noThirdParty: "还没有安装第三方插件", official: "官方插件", local: "本地插件", thirdParty: "第三方插件", warning: "插件代码会在当前页面内直接执行,可访问本地数据(包含 AI API Key)。请仅安装你信任来源的插件。", aiConfigRequired: "AI 配置未就绪,请先在设置里配置模型与密钥", interactiveTitle: "当前:交互中。点击切回「移动」——拖动可移动节点", movableTitle: "当前:可移动。点击切到「交互」——可操作节点内容(如转动全景)", move: "移动", interact: "交互" },
|
||||
promptPanel: { video: "描述要生成的视频内容", audio: "描述要生成的音频内容", image: "描述要生成的图片内容", text: "请输入你想要生成的文本内容", editImage: "请输入你想要把这张图修改成什么", editText: "请输入你想要将本段文本修改成什么", expandEditor: "放大编辑", editorTitle: "编辑提示词", stopGeneration: "停止生成", generate: "生成", stop: "停止" },
|
||||
composer: { title: "组装提示词", description: "@ 引用已连接资产,发送前按当前连接重新编号", placeholder: "输入提示词,按 @ 引用连接的图片或文本", imagePreview: "引用图片预览", resources: { image: "图片{{index}}", video: "视频{{index}}", audio: "音频{{index}}", text: "文本{{index}}" } },
|
||||
controls: { ratio: "比例", duplicate: "复制", delete: "删除", images: "{{count}} 张", reasoning: "推理" },
|
||||
controls: { ratio: "比例", duplicate: "复制", delete: "删除", images: "{{count}} 张", generations: "{{count}} 次", reasoning: "推理" },
|
||||
generation: { interrupted: "页面刷新后生成已中断,请重新生成。", front: "正面视角", rotateRight: "向右旋转 {{angle}} 度", rotateLeft: "向左旋转 {{angle}} 度", level: "水平视角", topDown: "俯视 {{angle}} 度", lowAngle: "仰视 {{angle}} 度", angleLabel: "AI 多角度:{{horizontal}},{{pitch}},镜头距离 {{distance}},{{lens}}镜头", anglePrompt: "基于参考图重新生成同一主体的新视角,保持主体、颜色、材质和画面风格一致,不要只做透视变形。{{angle}}。" },
|
||||
agentOps: { add_node: "新增节点", update_node: "更新节点", delete_node: "删除节点", delete_connections: "删除连线", connect_nodes: "连接", set_viewport: "调整视图", select_nodes: "选择节点", run_generation: "触发生成" },
|
||||
pluginErrors: { invalidExport: "插件未导出有效对象", missingFields: "插件缺少 id 或 nodes", downloadFailed: "下载失败 (HTTP {{status}})", registryFailed: "获取官方插件列表失败 (HTTP {{status}})" },
|
||||
|
||||
@@ -221,8 +221,6 @@ function InfiniteCanvasPage() {
|
||||
const [toolbarNodeId, setToolbarNodeId] = useState<string | null>(null);
|
||||
const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false);
|
||||
const [dialogNodeId, setDialogNodeId] = useState<string | null>(null);
|
||||
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
||||
const [editRequestNonce, setEditRequestNonce] = useState(0);
|
||||
const [infoNodeId, setInfoNodeId] = useState<string | null>(null);
|
||||
const [pluginManagerOpen, setPluginManagerOpen] = useState(false);
|
||||
const [cropNodeId, setCropNodeId] = useState<string | null>(null);
|
||||
@@ -714,7 +712,6 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setToolbarNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setDialogNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setEditingNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setInfoNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setCropNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
setMaskEditNodeId((current) => (current && allIds.has(current) ? null : current));
|
||||
@@ -743,7 +740,6 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setDialogNodeId(null);
|
||||
setEditingNodeId(null);
|
||||
}, [cancelPendingConnectionCreate]);
|
||||
|
||||
const clearCanvas = useCallback(() => {
|
||||
@@ -979,7 +975,6 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setDialogNodeId(null);
|
||||
setEditingNodeId(null);
|
||||
if (pendingConnectionCreateRef.current) cancelPendingConnectionCreate();
|
||||
if (event.button !== 0) return;
|
||||
|
||||
@@ -1105,9 +1100,7 @@ function InfiniteCanvasPage() {
|
||||
if (wasClick && clickedNodeId) {
|
||||
const clickedNode = nodesRef.current.find((node) => node.id === clickedNodeId);
|
||||
const clickedDefinition = clickedNode ? getNodeDefinition(clickedNode.type) : undefined;
|
||||
if (clickedNode?.type === CanvasNodeType.Text) {
|
||||
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
||||
} else if (clickedDefinition?.hidePanel) {
|
||||
if (clickedDefinition?.hidePanel) {
|
||||
// Clicking a display-only plugin node selects it without opening a lower panel.
|
||||
setDialogNodeId((current) => (current === clickedNodeId ? current : null));
|
||||
} else if (clickedNode?.type !== CanvasNodeType.Group) {
|
||||
@@ -1398,7 +1391,6 @@ function InfiniteCanvasPage() {
|
||||
setHoveredNodeId(null);
|
||||
setToolbarNodeId(null);
|
||||
setDialogNodeId(null);
|
||||
setEditingNodeId(null);
|
||||
setInfoNodeId(null);
|
||||
setCropNodeId(null);
|
||||
setMaskEditNodeId(null);
|
||||
@@ -1523,15 +1515,6 @@ function InfiniteCanvasPage() {
|
||||
setDialogNodeId(id);
|
||||
}, []);
|
||||
|
||||
const openTextEditor = useCallback((node: CanvasNodeData) => {
|
||||
if (node.type !== CanvasNodeType.Text) return;
|
||||
setSelectedNodeIds(new Set([node.id]));
|
||||
setSelectedConnectionId(null);
|
||||
setDialogNodeId(node.id);
|
||||
setEditingNodeId(node.id);
|
||||
setEditRequestNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const handleNodePromptChange = useCallback((nodeId: string, prompt: string) => {
|
||||
setNodes((prev) => prev.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, prompt } } : node)));
|
||||
}, []);
|
||||
@@ -2331,13 +2314,13 @@ function InfiniteCanvasPage() {
|
||||
|
||||
let streamed = "";
|
||||
const isConfigNode = sourceNode?.type === CanvasNodeType.Config;
|
||||
const textCount = isConfigNode ? getGenerationCount(generationConfig.count) : 1;
|
||||
const textCount = getGenerationCount(String(sourceNode?.metadata?.textCount || 1));
|
||||
const parentConfig = NODE_DEFAULT_SIZE[isConfigNode ? CanvasNodeType.Config : CanvasNodeType.Text];
|
||||
const textConfig = NODE_DEFAULT_SIZE[CanvasNodeType.Text];
|
||||
const parentPosition = sourceNode?.position || { x: 0, y: 0 };
|
||||
const childIds = isConfigNode || editingTextNode ? Array.from({ length: textCount }, () => nanoid()) : [];
|
||||
const childIds = isConfigNode || editingTextNode || textCount > 1 ? Array.from({ length: textCount }, () => nanoid()) : [];
|
||||
pendingChildIds = childIds;
|
||||
if (isConfigNode || editingTextNode) {
|
||||
if (childIds.length) {
|
||||
const childNodes: CanvasNodeData[] = childIds.map((id, index) => ({
|
||||
id,
|
||||
type: CanvasNodeType.Text,
|
||||
@@ -2842,7 +2825,6 @@ function InfiniteCanvasPage() {
|
||||
isFocusRelated={activeNodeId === node.id}
|
||||
isConnectionTarget={connectionTargetNodeId === node.id}
|
||||
isConnecting={Boolean(connectingParams)}
|
||||
editRequestNonce={editingNodeId === node.id ? editRequestNonce : 0}
|
||||
showPanel={!isNodeResizing && dialogNodeId === node.id && !selectionBox && !getNodeDefinition(node.type)?.hidePanel}
|
||||
groupChildCount={groupChildCountById.get(node.id) || 0}
|
||||
isGroupDropTarget={dropTargetGroupId === node.id}
|
||||
@@ -2909,7 +2891,6 @@ function InfiniteCanvasPage() {
|
||||
onKeep={keepNodeToolbar}
|
||||
onLeave={hideNodeToolbar}
|
||||
onInfo={(node) => setInfoNodeId(node.id)}
|
||||
onEditText={openTextEditor}
|
||||
onDecreaseFont={(node) => handleFontSizeChange(node.id, Math.max(10, (node.metadata?.fontSize || 14) - 2))}
|
||||
onIncreaseFont={(node) => handleFontSizeChange(node.id, Math.min(32, (node.metadata?.fontSize || 14) + 2))}
|
||||
onToggleDialog={(node) => setDialogNodeId((current) => (current === node.id ? null : node.id))}
|
||||
|
||||
@@ -52,6 +52,7 @@ export type CanvasNodeMetadata = {
|
||||
quality?: string;
|
||||
background?: string;
|
||||
count?: number;
|
||||
textCount?: number;
|
||||
seconds?: string;
|
||||
vquality?: string;
|
||||
generateAudio?: string;
|
||||
|
||||
Reference in New Issue
Block a user