mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-24 06:54:06 +08:00
feat(canvas): add canvas default image count configuration and update model picker capabilities
This commit is contained in:
@@ -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 文本接口在文档站容器中可访问。
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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({
|
||||
<AssistantModeSwitch mode={mode} theme={theme} onChange={onModeChange} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.imageModel || config.model} onChange={(model) => onConfigChange("imageModel", model)} capability="image" onMissingConfig={onMissingConfig} />
|
||||
<CanvasImageSettingsPopover config={config} placement="topRight" getPopupContainer={() => document.body} buttonClassName="canvas-composer-settings canvas-composer-icon !h-8 !min-w-8 !rounded-full !px-2" onConfigChange={onConfigChange} onMissingConfig={onMissingConfig} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} onMissingConfig={onMissingConfig} />
|
||||
<ModelPicker className="h-8 shrink-0" config={config} value={config.textModel || config.model} onChange={(model) => onConfigChange("textModel", model)} capability="text" onMissingConfig={onMissingConfig} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -38,7 +38,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, inputs, o
|
||||
const theme = canvasThemes[useThemeStore((state) => 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
|
||||
</div>
|
||||
|
||||
<div className={`mb-2 grid min-w-0 cursor-default items-center gap-2 ${mode === "text" ? "grid-cols-1" : "grid-cols-[minmax(0,1fr)_148px]"}`} onMouseDown={(event) => event.stopPropagation()}>
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} fullWidth />
|
||||
<ModelPicker className="canvas-compact-control h-10" config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability={mode} onMissingConfig={() => openConfigDialog(true)} fullWidth />
|
||||
{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))} />
|
||||
) : 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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
<CanvasPromptLibrary onSelect={updatePrompt} />
|
||||
{mode === "image" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="image" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasImageSettingsPopover
|
||||
config={config}
|
||||
placement="topLeft"
|
||||
@@ -92,11 +92,11 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
</>
|
||||
) : mode === "video" ? (
|
||||
<>
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="video" onMissingConfig={() => openConfigDialog(true)} />
|
||||
<CanvasVideoSettingsPopover config={config} buttonClassName="!h-10 !max-w-[170px] !justify-start !rounded-full !px-3" onConfigChange={(key, value) => onConfigChange(node.id, videoConfigPatch(key, value))} />
|
||||
</>
|
||||
) : (
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} onMissingConfig={() => openConfigDialog(true)} />
|
||||
<ModelPicker config={config} value={config.model} onChange={(model) => onConfigChange(node.id, { model })} capability="text" onMissingConfig={() => openConfigDialog(true)} />
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
@@ -134,7 +134,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),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: {
|
||||
<>
|
||||
<label className="col-span-2 block min-w-0 sm:col-span-1">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">模型</span>
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("imageModel", value)} capability="image" fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<ImageSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" maxCount={10} />
|
||||
|
||||
@@ -484,7 +484,7 @@ function GenerationSettings({ config, model, updateConfig, openConfigDialog }: {
|
||||
<>
|
||||
<label className="col-span-2 block min-w-0 sm:col-span-1">
|
||||
<span className="mb-1.5 block text-sm font-semibold sm:mb-2 sm:text-base">模型</span>
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("videoModel", value)} fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
<ModelPicker config={config} value={model} onChange={(value) => updateConfig("videoModel", value)} capability="video" fullWidth onMissingConfig={() => openConfigDialog(false)} />
|
||||
</label>
|
||||
<div className="col-span-2">
|
||||
<VideoSettingsPanel config={config} onConfigChange={(key, value) => updateConfig(key, value)} theme={theme} showTitle={false} className="space-y-4" />
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form.Item label="默认生图模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} fullWidth />
|
||||
<ModelPicker config={modelConfig} value={modelConfig.imageModel} onChange={(model) => updateConfig("imageModel", model)} capability="image" fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认视频模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.videoModel} onChange={(model) => updateConfig("videoModel", model)} fullWidth />
|
||||
<ModelPicker config={modelConfig} value={modelConfig.videoModel} onChange={(model) => updateConfig("videoModel", model)} capability="video" fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认文本模型" className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} fullWidth />
|
||||
<ModelPicker config={modelConfig} value={modelConfig.textModel} onChange={(model) => updateConfig("textModel", model)} capability="text" fullWidth />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={15}
|
||||
value={config.canvasImageCount}
|
||||
onChange={(event) => updateConfig("canvasImageCount", event.target.value)}
|
||||
onBlur={(event) => updateConfig("canvasImageCount", normalizeImageCount(event.target.value))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
{effectiveMode === "local" ? (
|
||||
@@ -134,3 +149,7 @@ export function AppConfigModal() {
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeImageCount(value: string) {
|
||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="__empty__" disabled>
|
||||
{config.channelMode === "remote" ? "暂无可用模型" : "请先到配置里拉取模型列表"}
|
||||
{emptyModelLabel(config, capability)}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
@@ -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 (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
|
||||
@@ -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<ConfigStore>()(
|
||||
partialize: (state) => ({ config: state.config }),
|
||||
merge: (persisted, current) => {
|
||||
const config = { ...defaultConfig, ...((persisted as Partial<ConfigStore>).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" } };
|
||||
},
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user