mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-07-29 20:34:59 +08:00
feat(config): enhance model configuration with support for audio models and improve UI for user preferences
This commit is contained in:
@@ -5,7 +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 文本接口在文档站容器中可访问。
|
||||
|
||||
@@ -1,11 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { App, Button, Form, Input, Modal, Segmented } from "antd";
|
||||
import { App, Button, Form, Input, Modal, Segmented, Select } from "antd";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { fetchImageModels } from "@/services/api/image";
|
||||
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { filterModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
type ModelGroup = {
|
||||
capability: ModelCapability;
|
||||
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
|
||||
defaultLabel: string;
|
||||
optionsLabel: string;
|
||||
};
|
||||
|
||||
const modelGroups: ModelGroup[] = [
|
||||
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
|
||||
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
|
||||
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
|
||||
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
|
||||
];
|
||||
|
||||
export function AppConfigModal() {
|
||||
const { message } = App.useApp();
|
||||
@@ -22,6 +37,7 @@ export function AppConfigModal() {
|
||||
const allowCustomChannel = modelChannel?.allowCustomChannel === true;
|
||||
const effectiveMode = allowCustomChannel ? config.channelMode : "remote";
|
||||
const modelConfig = effectiveMode === "remote" ? effectiveConfig : config;
|
||||
const modelOptions = config.models.map((model) => ({ label: model, value: model }));
|
||||
|
||||
const finishConfig = () => {
|
||||
setConfigDialogOpen(false);
|
||||
@@ -44,10 +60,20 @@ export function AppConfigModal() {
|
||||
const imageModels = filterModelsByCapability(models, "image");
|
||||
const videoModels = filterModelsByCapability(models, "video");
|
||||
const textModels = filterModelsByCapability(models, "text");
|
||||
const audioModels = filterModelsByCapability(models, "audio");
|
||||
const nextImageModels = resolveNextCapabilityModels(config.imageModels, imageModels, models);
|
||||
const nextVideoModels = resolveNextCapabilityModels(config.videoModels, videoModels, models);
|
||||
const nextTextModels = resolveNextCapabilityModels(config.textModels, textModels, models);
|
||||
const nextAudioModels = resolveNextCapabilityModels(config.audioModels, audioModels, models);
|
||||
updateConfig("models", models);
|
||||
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]);
|
||||
updateConfig("imageModels", nextImageModels);
|
||||
updateConfig("videoModels", nextVideoModels);
|
||||
updateConfig("textModels", nextTextModels);
|
||||
updateConfig("audioModels", nextAudioModels);
|
||||
if (nextImageModels.length && !nextImageModels.includes(config.imageModel)) updateConfig("imageModel", nextImageModels[0]);
|
||||
if (nextVideoModels.length && !nextVideoModels.includes(config.videoModel)) updateConfig("videoModel", nextVideoModels[0]);
|
||||
if (nextTextModels.length && !nextTextModels.includes(config.textModel)) updateConfig("textModel", nextTextModels[0]);
|
||||
if (nextAudioModels.length && !nextAudioModels.includes(config.audioModel)) updateConfig("audioModel", nextAudioModels[0]);
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
@@ -56,18 +82,25 @@ export function AppConfigModal() {
|
||||
}
|
||||
};
|
||||
|
||||
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
|
||||
const next = uniqueModels(models);
|
||||
updateConfig(group.modelsKey, next);
|
||||
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">模型和密钥</div>
|
||||
<div className="text-lg font-semibold">配置与用户偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">模型、渠道和画布默认行为</div>
|
||||
</div>
|
||||
}
|
||||
open={isConfigOpen}
|
||||
width={760}
|
||||
width={960}
|
||||
centered
|
||||
onCancel={() => setConfigDialogOpen(false)}
|
||||
styles={{ body: { maxHeight: "72vh", overflowY: "auto", paddingRight: 18 } }}
|
||||
footer={
|
||||
<Button type="primary" onClick={finishConfig}>
|
||||
完成
|
||||
@@ -77,7 +110,7 @@ export function AppConfigModal() {
|
||||
<div className="pt-1">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
{allowCustomChannel ? (
|
||||
<Form.Item label="渠道模式" className="mb-4">
|
||||
<Form.Item label="渠道模式" className="mb-5">
|
||||
<Segmented
|
||||
block
|
||||
size="middle"
|
||||
@@ -100,7 +133,7 @@ export function AppConfigModal() {
|
||||
<Input.Password value={config.apiKey} onChange={(event) => updateConfig("apiKey", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
|
||||
<div className="mb-5 flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-3 py-2 dark:border-stone-800">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium">模型列表</div>
|
||||
<div className="mt-1 text-xs text-stone-500">当前已保存 {config.models.length} 个模型</div>
|
||||
@@ -111,21 +144,41 @@ export function AppConfigModal() {
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
|
||||
<div className="mb-5 rounded-lg border border-stone-200 p-3 text-sm text-stone-500 dark:border-stone-800">
|
||||
<div className="font-medium text-stone-900 dark:text-stone-100">云端渠道</div>
|
||||
<div className="mt-1">由系统后台渠道转发请求,当前可用 {modelChannel?.availableModels.length || 0} 个模型。</div>
|
||||
</div>
|
||||
)}
|
||||
<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)} capability="image" fullWidth />
|
||||
</Form.Item>
|
||||
<Form.Item label="默认视频模型" className="mb-4">
|
||||
<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)} capability="text" fullWidth />
|
||||
</Form.Item>
|
||||
{effectiveMode === "local" ? (
|
||||
<section className="mb-5 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3">
|
||||
<div className="text-sm font-semibold">本地模型可选项</div>
|
||||
<div className="mt-1 text-xs text-stone-500">从已拉取模型中选择哪些模型可进入各类下拉。</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelsKey} label={group.optionsLabel} className="mb-0">
|
||||
<Select
|
||||
mode="multiple"
|
||||
showSearch
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder={config.models.length ? `请选择${group.optionsLabel}` : "请先拉取模型列表"}
|
||||
value={config[group.modelsKey]}
|
||||
options={modelOptions}
|
||||
onChange={(models) => updateCapabilityModels(group, models)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-4">
|
||||
<ModelPicker config={modelConfig} value={modelConfig[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
@@ -153,3 +206,13 @@ export function AppConfigModal() {
|
||||
function normalizeImageCount(value: string) {
|
||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
||||
}
|
||||
|
||||
function resolveNextCapabilityModels(current: string[], suggested: string[], allModels: string[]) {
|
||||
const available = new Set(allModels);
|
||||
const kept = uniqueModels(current).filter((model) => available.has(model));
|
||||
return kept.length ? kept : suggested;
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Cpu } from "lucide-react";
|
||||
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { filterModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
@@ -21,7 +21,7 @@ type 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(() => 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 options = useMemo(() => Array.from(new Set([...(config.channelMode === "local" && !capability ? [value] : []), ...selectableModelsByCapability(config, capability)].filter((model): model is string => Boolean(model)))), [capability, config, value]);
|
||||
const current = value || "";
|
||||
|
||||
useEffect(() => {
|
||||
@@ -37,10 +37,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
open={open}
|
||||
value={current}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (nextOpen && !options.length && config.channelMode === "local") {
|
||||
onMissingConfig?.();
|
||||
return;
|
||||
}
|
||||
if (nextOpen && !options.length && config.channelMode === "local") onMissingConfig?.();
|
||||
if (nextOpen) window.dispatchEvent(new CustomEvent("model-picker-open", { detail: pickerId }));
|
||||
setOpen(nextOpen);
|
||||
}}
|
||||
@@ -87,8 +84,9 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
}
|
||||
|
||||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : "";
|
||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||||
if (config.channelMode === "remote") return `暂无可用${label}模型`;
|
||||
if (capability && config.models.length) return "请先在上方配置可选模型";
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里拉取模型列表";
|
||||
}
|
||||
|
||||
|
||||
@@ -15,12 +15,17 @@ export type AiConfig = {
|
||||
imageModel: string;
|
||||
videoModel: string;
|
||||
textModel: string;
|
||||
audioModel: string;
|
||||
videoSeconds: string;
|
||||
vquality: string;
|
||||
videoGenerateAudio: string;
|
||||
videoWatermark: string;
|
||||
systemPrompt: string;
|
||||
models: string[];
|
||||
imageModels: string[];
|
||||
videoModels: string[];
|
||||
textModels: string[];
|
||||
audioModels: string[];
|
||||
quality: string;
|
||||
size: string;
|
||||
count: string;
|
||||
@@ -28,7 +33,7 @@ export type AiConfig = {
|
||||
};
|
||||
|
||||
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
|
||||
export type ModelCapability = "image" | "video" | "text";
|
||||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||
|
||||
export const defaultConfig: AiConfig = {
|
||||
channelMode: "local",
|
||||
@@ -38,12 +43,17 @@ export const defaultConfig: AiConfig = {
|
||||
imageModel: "gpt-image-2",
|
||||
videoModel: "grok-imagine-video",
|
||||
textModel: "gpt-5.5",
|
||||
audioModel: "",
|
||||
videoSeconds: "6",
|
||||
vquality: "720",
|
||||
videoGenerateAudio: "true",
|
||||
videoWatermark: "false",
|
||||
systemPrompt: "",
|
||||
models: [],
|
||||
imageModels: [],
|
||||
videoModels: [],
|
||||
textModels: [],
|
||||
audioModels: [],
|
||||
quality: "auto",
|
||||
size: "1:1",
|
||||
count: "1",
|
||||
@@ -71,18 +81,25 @@ function resolveEffectiveConfig(config: AiConfig, modelChannel: AdminPublicSetti
|
||||
const textModels = filterModelsByCapability(models, "text");
|
||||
const imageModels = filterModelsByCapability(models, "image");
|
||||
const videoModels = filterModelsByCapability(models, "video");
|
||||
const audioModels = filterModelsByCapability(models, "audio");
|
||||
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);
|
||||
const fallbackAudioModel = preferredModel(audioModels, isAudioModelName);
|
||||
return {
|
||||
...config,
|
||||
channelMode,
|
||||
models,
|
||||
imageModels,
|
||||
videoModels,
|
||||
textModels,
|
||||
audioModels,
|
||||
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,
|
||||
audioModel: audioModels.includes(config.audioModel) ? config.audioModel : fallbackAudioModel,
|
||||
systemPrompt: modelChannel.systemPrompt,
|
||||
};
|
||||
}
|
||||
@@ -102,17 +119,23 @@ function isVideoModelName(model: string) {
|
||||
|
||||
function isImageModelName(model: string) {
|
||||
const value = model.toLowerCase();
|
||||
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"));
|
||||
return !isVideoModelName(model) && !isAudioModelName(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 isAudioModelName(model: string) {
|
||||
const value = model.toLowerCase();
|
||||
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("whisper") || value.includes("transcribe") || value.includes("voice") || value.includes("music") || value.includes("sound");
|
||||
}
|
||||
|
||||
function isTextModelName(model: string) {
|
||||
return !isImageModelName(model) && !isVideoModelName(model);
|
||||
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
|
||||
}
|
||||
|
||||
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
|
||||
if (!capability) return true;
|
||||
if (capability === "image") return isImageModelName(model);
|
||||
if (capability === "video") return isVideoModelName(model);
|
||||
if (capability === "audio") return isAudioModelName(model);
|
||||
return isTextModelName(model);
|
||||
}
|
||||
|
||||
@@ -120,6 +143,15 @@ export function filterModelsByCapability(models: string[], capability?: ModelCap
|
||||
return capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models;
|
||||
}
|
||||
|
||||
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
|
||||
if (!capability) return config.models;
|
||||
return config[modelListKey(capability)];
|
||||
}
|
||||
|
||||
function modelListKey(capability: ModelCapability) {
|
||||
return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels";
|
||||
}
|
||||
|
||||
function isAiConfigReady(config: AiConfig, model: string) {
|
||||
return Boolean(model.trim()) && (config.channelMode === "remote" || Boolean(config.baseUrl.trim() && config.apiKey.trim()));
|
||||
}
|
||||
@@ -157,13 +189,37 @@ export const useConfigStore = create<ConfigStore>()(
|
||||
name: CONFIG_STORE_KEY,
|
||||
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", canvasImageCount: config.canvasImageCount || "3" } };
|
||||
const persistedConfig = ((persisted as Partial<ConfigStore>).config || {}) as Partial<AiConfig>;
|
||||
const config = { ...defaultConfig, ...persistedConfig };
|
||||
return {
|
||||
...current,
|
||||
config: {
|
||||
...config,
|
||||
channelMode: config.channelMode || "remote",
|
||||
imageModel: config.imageModel || config.model,
|
||||
videoModel: config.videoModel || "grok-imagine-video",
|
||||
textModel: config.textModel || config.model,
|
||||
audioModel: config.audioModel || "",
|
||||
videoSeconds: config.videoSeconds || "6",
|
||||
vquality: config.vquality || "720",
|
||||
videoGenerateAudio: config.videoGenerateAudio || "true",
|
||||
videoWatermark: config.videoWatermark || "false",
|
||||
canvasImageCount: config.canvasImageCount || "3",
|
||||
imageModels: Array.isArray(persistedConfig.imageModels) ? normalizeModelList(config.imageModels) : filterModelsByCapability(config.models, "image"),
|
||||
videoModels: Array.isArray(persistedConfig.videoModels) ? normalizeModelList(config.videoModels) : filterModelsByCapability(config.models, "video"),
|
||||
textModels: Array.isArray(persistedConfig.textModels) ? normalizeModelList(config.textModels) : filterModelsByCapability(config.models, "text"),
|
||||
audioModels: Array.isArray(persistedConfig.audioModels) ? normalizeModelList(config.audioModels) : filterModelsByCapability(config.models, "audio"),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
function normalizeModelList(models: string[]) {
|
||||
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
export function useEffectiveConfig() {
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const modelChannel = useConfigStore((state) => state.publicSettings?.modelChannel || null);
|
||||
|
||||
Reference in New Issue
Block a user