diff --git a/docs/pending-test.md b/docs/pending-test.md index 774be2d..ccbcdb5 100644 --- a/docs/pending-test.md +++ b/docs/pending-test.md @@ -1,5 +1,6 @@ # 待测试 +- 画布右侧助手和节点下方对话框的生图设置弹层改为紧凑图像设置面板,面板只包含质量、尺寸、宽高比和生成数量选择,模型选择保留在对话框底部工具栏;需要确认节点内质量可正常选择并参与生成、尺寸可编辑、宽高比预览显示正常、auto 不重复显示、张数每行 4 个且自定义输入框能显示当前值、实际生图参数正常;节点设置面板打开时会隐藏节点悬浮工具条,避免遮挡质量选择。 - 管理后台 `/admin/prompts` 新增提示词批量删除,需要确认多选、确认弹窗、删除后列表刷新和筛选条件下删除行为。 - 管理后台 `/admin/settings` 的私有配置新增提示词定时同步开关和 Cron 表达式;开启后后端会按配置同步内置 GitHub 远程提示词源,需要确认保存配置和到点同步行为。 - 管理后台 `/admin/settings` 中的渠道模型列表获取和模型测试已改为走后端接口,前端不再直接读取或转发 API Key,需要确认保存后、编辑中和已有渠道三种场景都能正常拉取模型和测试连通性。 diff --git a/web/public/icons/claude.svg b/web/public/icons/claude.svg new file mode 100644 index 0000000..62dc0db --- /dev/null +++ b/web/public/icons/claude.svg @@ -0,0 +1 @@ +Claude \ No newline at end of file diff --git a/web/public/icons/gemini.svg b/web/public/icons/gemini.svg new file mode 100644 index 0000000..62681df --- /dev/null +++ b/web/public/icons/gemini.svg @@ -0,0 +1 @@ +Gemini \ No newline at end of file diff --git a/web/public/icons/openai.svg b/web/public/icons/openai.svg new file mode 100644 index 0000000..78caf4f --- /dev/null +++ b/web/public/icons/openai.svg @@ -0,0 +1 @@ +OpenAI \ No newline at end of file diff --git a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx index fa4756f..736d3af 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -267,6 +267,7 @@ function InfiniteCanvasPage() { const [assetPickerTab, setAssetPickerTab] = useState("my-assets"); const [projectLoaded, setProjectLoaded] = useState(false); const [toolbarNodeId, setToolbarNodeId] = useState(null); + const [nodeImageSettingsOpen, setNodeImageSettingsOpen] = useState(false); const [dialogNodeId, setDialogNodeId] = useState(null); const [editingNodeId, setEditingNodeId] = useState(null); const [editRequestNonce, setEditRequestNonce] = useState(0); @@ -372,6 +373,10 @@ function InfiniteCanvasPage() { updateProject(projectId, { nodes, connections, chatSessions, activeChatId, backgroundMode }); }, [activeChatId, backgroundMode, chatSessions, connections, nodes, projectId, projectLoaded, updateProject]); + useEffect(() => { + if (!dialogNodeId) setNodeImageSettingsOpen(false); + }, [dialogNodeId]); + useEffect(() => { if (!projectLoaded) return; if (viewportSaveTimerRef.current) clearTimeout(viewportSaveTimerRef.current); @@ -444,13 +449,13 @@ function InfiniteCanvasPage() { }, []); const keepNodeToolbar = useCallback((nodeId: string) => { - if (nodeDraggingRef.current) return; + if (nodeDraggingRef.current || nodeImageSettingsOpen) return; if (toolbarHideTimerRef.current) { clearTimeout(toolbarHideTimerRef.current); toolbarHideTimerRef.current = null; } setToolbarNodeId(nodeId); - }, []); + }, [nodeImageSettingsOpen]); const hideNodeToolbar = useCallback(() => { if (toolbarHideTimerRef.current) clearTimeout(toolbarHideTimerRef.current); @@ -1891,6 +1896,10 @@ function InfiniteCanvasPage() { onPromptChange={handleNodePromptChange} onConfigChange={handleConfigNodeChange} onGenerate={handleGenerateNode} + onImageSettingsOpenChange={(open) => { + setNodeImageSettingsOpen(open); + if (open) setToolbarNodeId(null); + }} /> )} renderNodeContent={(contentNode) => ( @@ -1955,7 +1964,7 @@ function InfiniteCanvasPage() { {mode === "image" ? ( - + <> + onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} /> + + ) : ( onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} /> )} @@ -413,60 +417,6 @@ function AssistantComposer({ ); } -function ImageSettingsPopover({ config, onConfigChange, onMissingConfig }: { config: AiConfig; onConfigChange: (key: keyof AiConfig, value: string) => void; onMissingConfig: () => void }) { - const theme = canvasThemes[useThemeStore((state) => state.theme)]; - const quality = config.quality || "auto"; - const count = String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)))); - const sizes = ["1:1", "3:2", "2:3", "16:9", "9:16", "auto"]; - const customSize = sizes.includes(config.size || "auto") ? "" : config.size; - return ( - -
event.stopPropagation()}> -
图像设置
-
- 模型 - onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} fullWidth /> -
-
- 质量 - onConfigChange("quality", String(value))} options={[{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }]} /> -
-
- 宽高比 -
- {sizes.map((size) => ( - - ))} - onConfigChange("size", event.target.value.trim() || "auto")} /> -
-
-
- 生成数量 -
- onConfigChange("count", String(value))} options={["1", "2", "3", "4"].map((value) => ({ value, label: `${value} 张` }))} /> - onConfigChange("count", String(value || 1))} /> -
-
-
- - )} - > - -
- ); -} - function CanvasThemeProvider({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) { return ( diff --git a/web/src/app/(user)/canvas/components/canvas-image-settings-popover.tsx b/web/src/app/(user)/canvas/components/canvas-image-settings-popover.tsx new file mode 100644 index 0000000..0b4cb79 --- /dev/null +++ b/web/src/app/(user)/canvas/components/canvas-image-settings-popover.tsx @@ -0,0 +1,174 @@ +"use client"; + +import { type ReactNode } from "react"; +import { Settings2 } from "lucide-react"; +import { Button, ConfigProvider, Popover } from "antd"; + +import { canvasThemes } from "@/lib/canvas-theme"; +import { useThemeStore } from "@/stores/use-theme-store"; +import type { AiConfig } from "@/stores/use-config-store"; + +const qualityOptions = [{ value: "auto", label: "自动" }, { value: "high", label: "高" }, { value: "medium", label: "中" }, { value: "low", label: "低" }]; +const aspectOptions = [ + { value: "1:1", label: "1:1", width: 1024, height: 1024, icon: "square" }, + { value: "3:2", label: "3:2", width: 1536, height: 1024, icon: "landscape" }, + { value: "2:3", label: "2:3", width: 1024, height: 1536, icon: "portrait" }, + { value: "4:3", label: "4:3", width: 1344, height: 1024, icon: "landscape" }, + { value: "3:4", label: "3:4", width: 1024, height: 1344, icon: "portrait" }, + { value: "9:16", label: "9:16", width: 1024, height: 1792, icon: "portrait" }, + { value: "1:1-2k", label: "1:1(2k)", size: "2048x2048", width: 2048, height: 2048, icon: "square" }, + { value: "16:9-2k", label: "16:9(2k)", size: "2048x1152", width: 2048, height: 1152, icon: "landscape" }, + { value: "9:16-2k", label: "9:16(2k)", size: "1152x2048", width: 1152, height: 2048, icon: "portrait" }, + { value: "16:9-4k", label: "16:9(4k)", size: "3840x2160", width: 3840, height: 2160, icon: "landscape" }, + { value: "9:16-4k", label: "9:16(4k)", size: "2160x3840", width: 2160, height: 3840, icon: "portrait" }, + { value: "auto", label: "auto", width: 0, height: 0, icon: "auto" }, +]; + +type CanvasImageSettingsPopoverProps = { + config: AiConfig; + onConfigChange: (key: keyof AiConfig, value: string) => void; + onMissingConfig?: () => void; + onOpenChange?: (open: boolean) => void; + buttonClassName?: string; + getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; + placement?: "topLeft" | "top" | "topRight" | "bottomLeft" | "bottom" | "bottomRight"; +}; + +export function CanvasImageSettingsPopover({ config, onConfigChange, onOpenChange, buttonClassName, getPopupContainer, placement = "topLeft" }: CanvasImageSettingsPopoverProps) { + const theme = canvasThemes[useThemeStore((state) => state.theme)]; + const quality = config.quality || "auto"; + const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); + const activeSize = config.size || "auto"; + const selectedAspect = aspectOptions.find((item) => (item.size || item.value) === activeSize || item.value === activeSize); + const dimensions = readSizeDimensions(activeSize, selectedAspect || aspectOptions[0]); + const selectAspect = (value: string) => { + const option = aspectOptions.find((item) => item.value === value); + onConfigChange("size", option?.size || option?.value || "auto"); + }; + const updateDimension = (key: "width" | "height", value: number | null) => { + const next = Math.max(1, Math.floor(value || dimensions[key] || 1024)); + onConfigChange("size", `${key === "width" ? next : dimensions.width}x${key === "height" ? next : dimensions.height}`); + }; + + return ( + triggerNode.parentElement || document.body)} + onOpenChange={onOpenChange} + content={( + +
event.stopPropagation()}> +
图像设置
+
+ 质量 +
+ {qualityOptions.map((item) => onConfigChange("quality", item.value)}>{item.label})} +
+
+
+ 尺寸 +
+ updateDimension("width", value)} /> + + updateDimension("height", value)} /> +
+
+
+ 宽高比 +
+ {aspectOptions.map((item) => ( + + ))} +
+
+
+ 生成数量 +
+ {Array.from({ length: 10 }, (_, index) => index + 1).map((value) => onConfigChange("count", String(value))}>{value} 张)} + onConfigChange("count", String(value || 1))} /> +
+
+
+
+ )} + > + +
+ ); +} + +export function CanvasImageSettingsTheme({ theme, children }: { theme: (typeof canvasThemes)[keyof typeof canvasThemes]; children: ReactNode }) { + return ( + + {children} + + ); +} + +function OptionPill({ selected, theme, onClick, children }: { selected: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onClick: () => void; children: ReactNode }) { + return ( + + ); +} + +function DimensionInput({ prefix, value, disabled, theme, onChange }: { prefix: string; value: number; disabled: boolean; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) { + return ( + + ); +} + +function CountInput({ value, theme, onChange }: { value: number; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onChange: (value: number | null) => void }) { + return ( + + ); +} + +function AspectIcon({ type, width, height, color }: { type: string; width: number; height: number; color: string }) { + if (type === "auto") return null; + const ratio = width / Math.max(1, height); + const boxWidth = ratio >= 1 ? 28 : Math.max(12, 28 * ratio); + const boxHeight = ratio >= 1 ? Math.max(12, 28 / ratio) : 28; + return ( + + + + ); +} + +function SettingTitle({ children, color }: { children: string; color: string }) { + return
{children}
; +} + +function qualityLabel(value: string) { + return ({ auto: "自动", high: "高", medium: "中", low: "低" } as Record)[value] || value; +} + +function readSizeDimensions(size: string, fallback: { width: number; height: number }) { + const match = size?.match(/^(\d+)x(\d+)$/); + return { + width: match ? Number(match[1]) : fallback.width, + height: match ? Number(match[2]) : fallback.height, + }; +} diff --git a/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx b/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx index 84087f5..e5e6fd9 100644 --- a/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-node-prompt-panel.tsx @@ -2,14 +2,14 @@ import { useEffect, useState } from "react"; import { ArrowUp, LoaderCircle } from "lucide-react"; -import { Button, InputNumber } from "antd"; +import { Button } from "antd"; import { ModelPicker } from "@/components/model-picker"; import { defaultConfig, useConfigStore, type AiConfig } from "@/stores/use-config-store"; import { canvasThemes } from "@/lib/canvas-theme"; import { useThemeStore } from "@/stores/use-theme-store"; +import { CanvasImageSettingsPopover } from "./canvas-image-settings-popover"; import { CanvasPromptLibrary } from "./canvas-prompt-library"; -import { CanvasSizePicker } from "./canvas-size-picker"; import { CanvasNodeType, type CanvasGenerationMode, type CanvasNodeData } from "../types"; export type CanvasNodeGenerationMode = CanvasGenerationMode; @@ -20,9 +20,10 @@ type CanvasNodePromptPanelProps = { onPromptChange: (nodeId: string, prompt: string) => void; onConfigChange: (nodeId: string, patch: Partial) => void; onGenerate: (nodeId: string, mode: CanvasNodeGenerationMode, prompt: string) => void; + onImageSettingsOpenChange?: (open: boolean) => void; }; -export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate }: CanvasNodePromptPanelProps) { +export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfigChange, onGenerate, onImageSettingsOpenChange }: CanvasNodePromptPanelProps) { const globalConfig = useConfigStore((state) => state.config); const openConfigDialog = useConfigStore((state) => state.openConfigDialog); const theme = canvasThemes[useThemeStore((state) => state.theme)]; @@ -73,13 +74,21 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
- onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> {mode === "image" ? ( - onConfigChange(node.id, { size: value })} /> - ) : null} - {mode === "image" ? ( - onConfigChange(node.id, { count: Number(value) || 1 })} /> - ) : null} + <> + onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> + onConfigChange(node.id, key === "count" ? { count: Number(value) || 1 } : { [key]: value })} + onMissingConfig={() => openConfigDialog(true)} + onOpenChange={onImageSettingsOpenChange} + /> + + ) : ( + onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> + )}