From 39793e890dee266c1ee2d5c924340ff6895dc8bb Mon Sep 17 00:00:00 2001 From: HouYunFei <1844025705@qq.com> Date: Tue, 2 Jun 2026 17:07:31 +0800 Subject: [PATCH] feat(canvas): add canvas default image count configuration and update model picker capabilities --- docs/content/docs/progress/pending-test.mdx | 1 + .../(user)/canvas/[id]/canvas-client-page.tsx | 14 +++---- .../components/canvas-assistant-panel.tsx | 11 +++--- .../components/canvas-config-node-panel.tsx | 6 +-- .../components/canvas-node-prompt-panel.tsx | 8 ++-- web/src/app/(user)/image/page.tsx | 2 +- web/src/app/(user)/video/page.tsx | 2 +- .../components/layout/app-config-modal.tsx | 33 ++++++++++++---- web/src/components/model-picker.tsx | 15 +++++-- web/src/stores/use-config-store.ts | 39 +++++++++++++------ 10 files changed, 88 insertions(+), 43 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index ac212c3..2a56905 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,7 @@ description: 当前版本已实现但仍需人工验证的变更项 # 待测试 +- 配置弹窗新增“画布默认生图张数”,新建画布生图和配置节点会读取该值;模型下拉会按生图、视频、文本能力过滤可选模型,需要验证远程渠道和本地直连模型列表都能正确显示。 - 画布左上角菜单和右上角状态栏新增“文档”入口,会使用 `NEXT_PUBLIC_DOC_URL` 配置的地址并在新标签打开文档站;需要验证登录和未登录状态下顶部入口都可见。 - 文档站搜索改为中英文混合 tokenizer,中文正文、标题和短语会按中文词、单字、二元和三元片段建立索引;需要验证 `/api/search` 和搜索弹窗能命中文档中的中文关键词。 - 文档站改为 Next.js standalone server 输出,新增 `docs/Dockerfile`、`docs/docker-compose.yml` 和 `docs/docker-compose.local.yml` 独立运行入口;需要验证文档页、搜索接口和 LLM 文本接口在文档站容器中可访问。 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 776de60..733970d 100644 --- a/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx +++ b/web/src/app/(user)/canvas/[id]/canvas-client-page.tsx @@ -494,7 +494,7 @@ function InfiniteCanvasPage() { const createConnectedNode = useCallback( (type: CanvasNodeType.Image | CanvasNodeType.Text | CanvasNodeType.Config | CanvasNodeType.Video | CanvasNodeType.Audio, pending: PendingConnectionCreate) => { - const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: 3 } : undefined; + const metadata = type === CanvasNodeType.Config ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count) } : undefined; const newNode = createCanvasNode(type, pending.position, metadata); const connection = normalizeConnection(pending.connection.nodeId, newNode.id, [...nodesRef.current, newNode], pending.connection.handleType); if (!connection) { @@ -509,7 +509,7 @@ function InfiniteCanvasPage() { setPendingConnectionCreate(null); setConnecting(null); }, - [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting], + [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message, setConnecting], ); const cancelPendingConnectionCreate = useCallback(() => { @@ -625,7 +625,7 @@ function InfiniteCanvasPage() { ? { model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, - count: 3, + count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), } : undefined; const newNode = createCanvasNode(type, targetPosition, configMetadata); @@ -635,7 +635,7 @@ function InfiniteCanvasPage() { setSelectedConnectionId(null); if (type !== CanvasNodeType.Text && type !== CanvasNodeType.Audio) setDialogNodeId(newNode.id); }, - [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter], + [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, getCanvasCenter], ); const deleteNodes = useCallback( @@ -1990,7 +1990,7 @@ function InfiniteCanvasPage() { prompt: "", model: effectiveConfig.imageModel || effectiveConfig.model, size: effectiveConfig.size, - count: 3, + count: getGenerationCount(effectiveConfig.canvasImageCount || effectiveConfig.count), }, ); const connection = { id: nanoid(), fromNodeId: sourceNode.id, toNodeId: configNode.id }; @@ -2004,7 +2004,7 @@ function InfiniteCanvasPage() { setSelectedConnectionId(null); setDialogNodeId(configNode.id); }, - [effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message], + [effectiveConfig.canvasImageCount, effectiveConfig.count, effectiveConfig.imageModel, effectiveConfig.model, effectiveConfig.size, message], ); const insertAssistantImage = useCallback( @@ -2707,7 +2707,7 @@ function buildGenerationConfig(config: AiConfig, node: CanvasNodeData | undefine vquality: node?.metadata?.vquality || config.vquality || defaultConfig.vquality, videoGenerateAudio: node?.metadata?.generateAudio || config.videoGenerateAudio || defaultConfig.videoGenerateAudio, videoWatermark: node?.metadata?.watermark || config.videoWatermark || defaultConfig.videoWatermark, - count: String(node?.metadata?.count || (mode === "image" ? 3 : config.count) || defaultConfig.count), + count: String(node?.metadata?.count || (mode === "image" ? config.canvasImageCount || config.count : config.count) || defaultConfig.count), }; } diff --git a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx index 0b10338..edaffb8 100644 --- a/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx +++ b/web/src/app/(user)/canvas/components/canvas-assistant-panel.tsx @@ -80,6 +80,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS const selectedNodeKey = useMemo(() => Array.from(selectedNodeIds).sort().join(","), [selectedNodeIds]); const allSelectedReferences = useMemo(() => buildAssistantReferences(nodes, selectedNodeIds), [nodes, selectedNodeIds]); const selectedReferences = useMemo(() => allSelectedReferences.filter((item) => !removedReferenceIds.has(item.id)), [allSelectedReferences, removedReferenceIds]); + const assistantConfig = useMemo(() => ({ ...effectiveConfig, count: effectiveConfig.canvasImageCount || effectiveConfig.count }), [effectiveConfig]); const iconButtonStyle = { color: theme.node.muted }; useEffect(() => { @@ -140,7 +141,7 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS }; const sendMessage = async (text: string, nextMode: AssistantMode, history: CanvasAssistantMessage[], savedReferences?: CanvasAssistantReference[]) => { - const requestConfig = { ...effectiveConfig, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model }; + const requestConfig = { ...effectiveConfig, count: nextMode === "image" ? effectiveConfig.canvasImageCount || effectiveConfig.count : effectiveConfig.count, model: nextMode === "image" ? effectiveConfig.imageModel || effectiveConfig.model : effectiveConfig.textModel || effectiveConfig.model }; if (!isAiConfigReady(requestConfig, requestConfig.model)) { openConfigDialog(true); return; @@ -319,11 +320,11 @@ export function CanvasAssistantPanel({ nodes, selectedNodeIds, sessions, activeS prompt={prompt} isRunning={isRunning} references={selectedReferences} - config={effectiveConfig} + config={assistantConfig} onModeChange={setMode} onPromptChange={setPrompt} onSubmit={submit} - onConfigChange={updateConfig} + onConfigChange={(key, value) => updateConfig(key === "count" ? "canvasImageCount" : key, value)} onMissingConfig={() => openConfigDialog(true)} onRemoveReference={(id) => { setRemovedReferenceIds((prev) => new Set(prev).add(id)); @@ -429,11 +430,11 @@ function AssistantComposer({ {mode === "image" ? ( <> - onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} /> + onConfigChange("imageModel", model)} capability="image" onMissingConfig={onMissingConfig} /> document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} /> > ) : ( - onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} /> + onConfigChange("textModel", model)} capability="text" onMissingConfig={onMissingConfig} /> )} state.theme)]; const mode = node.metadata?.generationMode || "image"; const config = buildNodeConfig(globalConfig, node, mode); - const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(node.metadata?.count || 3)) || 1))); + const count = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); const credits = requestCreditCost({ channelMode: config.channelMode, modelCosts, model: config.model, count: mode === "image" ? count : 1 }); const chipStyle = { background: theme.node.fill, borderColor: theme.node.stroke, color: theme.node.text }; const textInputs = inputs.filter((input) => input.type === "text"); @@ -125,7 +125,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o event.stopPropagation()}> - onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth /> + onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth /> {mode === "video" ? ( onConfigChange(node.id, videoConfigPatch(key, value))} /> ) : mode === "image" ? ( @@ -405,7 +405,7 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can vquality: node.metadata?.vquality || globalConfig.vquality || defaultConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio || defaultConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark || defaultConfig.videoWatermark, - count: String(node.metadata?.count || (mode === "image" ? 3 : globalConfig.count) || defaultConfig.count), + count: String(node.metadata?.count || (mode === "image" ? globalConfig.canvasImageCount || globalConfig.count : globalConfig.count) || defaultConfig.count), }; } 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 62db038..3a1e770 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 @@ -80,7 +80,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi {mode === "image" ? ( <> - onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> + onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} /> ) : mode === "video" ? ( <> - onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> + onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} /> onConfigChange(node.id, videoConfigPatch(key, value))} /> > ) : ( - onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} /> + onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} /> )} 模型 - updateConfig("imageModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} /> + updateConfig("imageModel", value)} capability="image" fullWidth onMissingConfig={() => openConfigDialog(false)} /> updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" maxCount={10} /> diff --git a/web/src/app/(user)/video/page.tsx b/web/src/app/(user)/video/page.tsx index 2cbd3c7..0d8946d 100644 --- a/web/src/app/(user)/video/page.tsx +++ b/web/src/app/(user)/video/page.tsx @@ -484,7 +484,7 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: { <> 模型 - updateConfig("videoModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} /> + updateConfig("videoModel", value)} capability="video" fullWidth onMissingConfig={() => openConfigDialog(false)} /> updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" /> diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index 06164bb..882c35d 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -5,7 +5,7 @@ import { useState } from "react"; import { ModelPicker } from "@/components/model-picker"; import { fetchImageModels } from "@/services/api/image"; -import { useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; +import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store"; export function AppConfigModal() { const { message } = App.useApp(); @@ -41,10 +41,13 @@ export function AppConfigModal() { setLoadingModels(true); try { const models = await fetchImageModels(config); + const imageModels = filterModelsByCapability(models, "image"); + const videoModels = filterModelsByCapability(models, "video"); + const textModels = filterModelsByCapability(models, "text"); updateConfig("models", models); - if (models.length && !models.includes(config.imageModel)) updateConfig("imageModel", models[0]); - if (models.length && !models.includes(config.videoModel)) updateConfig("videoModel", models[0]); - if (models.length && !models.includes(config.textModel)) updateConfig("textModel", models[0]); + if (imageModels.length && !imageModels.includes(config.imageModel)) updateConfig("imageModel", imageModels[0]); + if (videoModels.length && !videoModels.includes(config.videoModel)) updateConfig("videoModel", videoModels[0]); + if (textModels.length && !textModels.includes(config.textModel)) updateConfig("textModel", textModels[0]); message.success("模型列表已更新"); } catch (error) { message.error(error instanceof Error ? error.message : "读取模型失败"); @@ -115,13 +118,25 @@ export function AppConfigModal() { )} - updateConfig("imageModel", model)} fullWidth /> + updateConfig("imageModel", model)} capability="image" fullWidth /> - updateConfig("videoModel", model)} fullWidth /> + updateConfig("videoModel", model)} capability="video" fullWidth /> - updateConfig("textModel", model)} fullWidth /> + updateConfig("textModel", model)} capability="text" fullWidth /> + + + + + updateConfig("canvasImageCount", event.target.value)} + onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))} + /> {effectiveMode === "local" ? ( @@ -134,3 +149,7 @@ export function AppConfigModal() { ); } + +function normalizeImageCount(value: string) { + return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3)))); +} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 8da2b3b..133c230 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -5,22 +5,23 @@ import { Cpu } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select"; import { cn } from "@/lib/utils"; -import type { AiConfig } from "@/stores/use-config-store"; +import { filterModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store"; type ModelPickerProps = { config: AiConfig; value?: string; onChange: (model: string) => void; + capability?: ModelCapability; className?: string; fullWidth?: boolean; placeholder?: string; onMissingConfig?: () => void; }; -export function ModelPicker({ config, value, onChange, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) { +export function ModelPicker({ config, value, onChange, capability, className, fullWidth = false, placeholder = "选择模型", onMissingConfig }: ModelPickerProps) { const pickerId = useId(); const [open, setOpen] = useState(false); - const options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter((model): model is string => Boolean(model)))), [config.channelMode, config.models, value]); + const options = useMemo(() => filterModelsByCapability(Array.from(new Set([...(config.channelMode === "local" ? [value] : []), ...config.models].filter((model): model is string => Boolean(model)))), capability), [capability, config.channelMode, config.models, value]); const current = value || ""; useEffect(() => { @@ -77,7 +78,7 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa )) ) : ( - {config.channelMode === "remote" ? "暂无可用模型" : "请先到配置里拉取模型列表"} + {emptyModelLabel(config, capability)} )} @@ -85,6 +86,12 @@ export function ModelPicker({ config, value, onChange, className, fullWidth = fa ); } +function emptyModelLabel(config: AiConfig, capability?: ModelCapability) { + const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : ""; + if (config.channelMode === "remote") return `暂无可用${label}模型`; + return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表"; +} + function ModelLabel({ model }: { model: string }) { return ( diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index 625fecb..d75d2f6 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -24,9 +24,11 @@ export type AiConfig = { quality: string; size: string; count: string; + canvasImageCount: string; }; export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store"; +export type ModelCapability = "image" | "video" | "text"; export const defaultConfig: AiConfig = { channelMode: "local", @@ -45,6 +47,7 @@ export const defaultConfig: AiConfig = { quality: "auto", size: "1:1", count: "1", + canvasImageCount: "3", }; type ConfigStore = { @@ -65,18 +68,21 @@ function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSetti const channelMode = modelChannel?.allowCustomChannel ? config.channelMode : "remote"; if (channelMode === "local" || !modelChannel) return { ...config, channelMode }; const models = modelChannel.availableModels; - const fallbackTextModel = validDefault(modelChannel.defaultTextModel, models) || preferredModel(models, isTextModelName); - const fallbackModel = validDefault(modelChannel.defaultModel, models) || fallbackTextModel || models[0] || ""; - const fallbackImageModel = validDefault(modelChannel.defaultImageModel, models) || preferredModel(models, isImageModelName) || fallbackModel; - const fallbackVideoModel = validDefault(modelChannel.defaultVideoModel, models) || preferredModel(models, isVideoModelName) || fallbackModel; + const textModels = filterModelsByCapability(models, "text"); + const imageModels = filterModelsByCapability(models, "image"); + const videoModels = filterModelsByCapability(models, "video"); + const fallbackTextModel = validDefault(modelChannel.defaultTextModel, textModels) || preferredModel(textModels, isTextModelName); + const fallbackModel = validDefault(modelChannel.defaultModel, textModels) || fallbackTextModel; + const fallbackImageModel = validDefault(modelChannel.defaultImageModel, imageModels) || preferredModel(imageModels, isImageModelName); + const fallbackVideoModel = validDefault(modelChannel.defaultVideoModel, videoModels) || preferredModel(videoModels, isVideoModelName); return { ...config, channelMode, models, - model: models.includes(config.model) ? config.model : fallbackModel, - imageModel: models.includes(config.imageModel) ? config.imageModel : fallbackImageModel, - videoModel: models.includes(config.videoModel) ? config.videoModel : fallbackVideoModel, - textModel: models.includes(config.textModel) ? config.textModel : fallbackTextModel || fallbackModel, + model: textModels.includes(config.model) ? config.model : fallbackModel, + imageModel: imageModels.includes(config.imageModel) ? config.imageModel : fallbackImageModel, + videoModel: videoModels.includes(config.videoModel) ? config.videoModel : fallbackVideoModel, + textModel: textModels.includes(config.textModel) ? config.textModel : fallbackTextModel || fallbackModel, systemPrompt: modelChannel.systemPrompt, }; } @@ -91,18 +97,29 @@ function preferredModel(models: string[], predicate: (model: string) => boolean) function isVideoModelName(model: string) { const value = model.toLowerCase(); - return value.includes("seedance") || value.includes("video"); + return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo"); } function isImageModelName(model: string) { const value = model.toLowerCase(); - return value.includes("seedream") || value.includes("gpt-image") || value.includes("image"); + return !isVideoModelName(model) && (value.includes("seedream") || value.includes("gpt-image") || value.includes("image") || value.includes("dall-e") || value.includes("dalle") || value.includes("imagen") || value.includes("flux") || value.includes("sdxl") || value.includes("stable-diffusion") || value.includes("midjourney")); } function isTextModelName(model: string) { return !isImageModelName(model) && !isVideoModelName(model); } +export function modelMatchesCapability(model: string, capability?: ModelCapability) { + if (!capability) return true; + if (capability === "image") return isImageModelName(model); + if (capability === "video") return isVideoModelName(model); + return isTextModelName(model); +} + +export function filterModelsByCapability(models: string[], capability?: ModelCapability) { + return capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models; +} + function isAiConfigReady(config: AiConfig, model: string) { return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim())); } @@ -141,7 +158,7 @@ export const useConfigStore = create()( partialize: (state) => ({ config: state.config }), merge: (persisted, current) => { const config = { ...defaultConfig, ...((persisted as Partial).config || {}) }; - return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, videoModel: config.videoModel || "grok-imagine-video", textModel: config.textModel || config.model, videoSeconds: config.videoSeconds || "6", vquality: config.vquality || "720", videoGenerateAudio: config.videoGenerateAudio || "true", videoWatermark: config.videoWatermark || "false" } }; + return { ...current, config: { ...config, channelMode: config.channelMode || "remote", imageModel: config.imageModel || config.model, videoModel: config.videoModel || "grok-imagine-video", textModel: config.textModel || config.model, videoSeconds: config.videoSeconds || "6", vquality: config.vquality || "720", videoGenerateAudio: config.videoGenerateAudio || "true", videoWatermark: config.videoWatermark || "false", canvasImageCount: config.canvasImageCount || "3" } }; }, }, ),