mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-04 16:14:22 +08:00
feat(model): enhance model configuration and script handling for audio and video capabilities
This commit is contained in:
@@ -146,9 +146,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const currentModel = node.metadata?.model;
|
||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
|
||||
@@ -134,9 +134,9 @@ function buildNodeConfig(globalConfig: AiConfig, node: CanvasNodeData, mode: Can
|
||||
const defaultModel = mode === "image" ? globalConfig.imageModel : mode === "video" ? globalConfig.videoModel : mode === "audio" ? globalConfig.audioModel : globalConfig.textModel;
|
||||
const fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||
const currentModel = node.metadata?.model;
|
||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
||||
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||
? currentModel
|
||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
||||
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||
? defaultModel
|
||||
: fallbackModel;
|
||||
return {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
|
||||
import { CircleAlert, Cloud, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
|
||||
import { Cloud, Pencil, Plus, RefreshCw, Trash2, Wifi } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { ModelPicker } from "@/components/model-picker";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import { ChannelEditorDrawer } from "@/components/layout/channel-editor-drawer";
|
||||
import { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import { createModelChannel, defaultBaseUrlForApiFormat, filterModelsByCapability, modelOptionLabel, modelOptionsFromChannels, normalizeModelOptionValue, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { createModelChannel, modelOptionsFromChannels, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore, type AiConfig, type ApiCallFormat, type ConfigTabKey, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelGroup = {
|
||||
capability: ModelCapability;
|
||||
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
|
||||
defaultLabel: string;
|
||||
optionsLabel: string;
|
||||
};
|
||||
|
||||
type WebdavDomainProgress = {
|
||||
@@ -26,15 +24,10 @@ type WebdavDomainProgress = {
|
||||
};
|
||||
|
||||
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: "音频模型可选项" },
|
||||
];
|
||||
|
||||
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Gemini", value: "gemini" },
|
||||
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
|
||||
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
|
||||
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
|
||||
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
|
||||
];
|
||||
|
||||
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
|
||||
@@ -58,7 +51,7 @@ function createWebdavDomainProgress(): Record<AppSyncDomainKey, WebdavDomainProg
|
||||
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
||||
const { message } = App.useApp();
|
||||
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
||||
const [loadingChannelId, setLoadingChannelId] = useState("");
|
||||
const [editingChannelId, setEditingChannelId] = useState("");
|
||||
const [testingWebdav, setTestingWebdav] = useState(false);
|
||||
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
||||
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
||||
@@ -70,8 +63,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
||||
const modelOptions = config.models.map((model) => ({ label: modelOptionLabel(config, model), value: model }));
|
||||
const webdavReady = Boolean(webdav.url.trim());
|
||||
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
|
||||
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
||||
|
||||
const saveConfig = (nextConfig: AiConfig) => {
|
||||
@@ -86,22 +79,12 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
clearPromptContinue();
|
||||
};
|
||||
|
||||
const updateChannels = (channels: ModelChannel[]) => {
|
||||
const nextConfig = withChannels(config, channels);
|
||||
saveConfig(nextConfig);
|
||||
};
|
||||
|
||||
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
|
||||
updateChannels(config.channels.map((channel) => (channel.id === id ? { ...channel, ...patch, models: patch.models ? uniqueModels(patch.models) : channel.models } : channel)));
|
||||
};
|
||||
|
||||
const updateChannelApiFormat = (channel: ModelChannel, apiFormat: ApiCallFormat) => {
|
||||
const baseUrl = !channel.baseUrl.trim() || channel.baseUrl.trim() === defaultBaseUrlForApiFormat(channel.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : channel.baseUrl;
|
||||
updateChannel(channel.id, { apiFormat, baseUrl });
|
||||
};
|
||||
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
|
||||
|
||||
const addChannel = () => {
|
||||
updateChannels([...config.channels, createModelChannel({ name: `渠道 ${config.channels.length + 1}` })]);
|
||||
const channel = createModelChannel({ name: `渠道 ${config.channels.length + 1}` });
|
||||
updateChannels([...config.channels, channel]);
|
||||
setEditingChannelId(channel.id);
|
||||
};
|
||||
|
||||
const deleteChannel = (id: string) => {
|
||||
@@ -112,46 +95,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
||||
};
|
||||
|
||||
const refreshChannelModels = async (channel: ModelChannel) => {
|
||||
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
||||
message.error("请先填写该渠道的 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingChannelId(channel.id);
|
||||
try {
|
||||
const models = await fetchChannelModels(channel);
|
||||
updateChannels(config.channels.map((item) => (item.id === channel.id ? { ...item, models } : item)));
|
||||
message.success(`${channel.name} 模型列表已更新`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingChannelId("");
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAllModels = async () => {
|
||||
const runnable = config.channels.filter((channel) => channel.baseUrl.trim() && channel.apiKey.trim());
|
||||
if (!runnable.length) {
|
||||
message.error("请先填写至少一个渠道的 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setLoadingChannelId("all");
|
||||
try {
|
||||
const entries = await Promise.all(runnable.map(async (channel) => [channel.id, await fetchChannelModels(channel)] as const));
|
||||
const modelMap = new Map(entries);
|
||||
updateChannels(config.channels.map((channel) => (modelMap.has(channel.id) ? { ...channel, models: modelMap.get(channel.id) || [] } : channel)));
|
||||
message.success("模型列表已更新");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "读取模型失败");
|
||||
} finally {
|
||||
setLoadingChannelId("");
|
||||
}
|
||||
};
|
||||
|
||||
const updateCapabilityModels = (group: ModelGroup, models: string[]) => {
|
||||
const next = uniqueModels(models.map((model) => normalizeModelOptionValue(model, config.channels)).filter(Boolean));
|
||||
updateConfig(group.modelsKey, next);
|
||||
if (!next.includes(config[group.modelKey])) updateConfig(group.modelKey, next[0] || "");
|
||||
const saveChannel = (channel: ModelChannel) => {
|
||||
updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item)));
|
||||
};
|
||||
|
||||
const testWebdav = async () => {
|
||||
@@ -215,107 +160,48 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
key: "channels",
|
||||
label: "渠道",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 rounded-md border border-amber-300 bg-amber-50 px-2.5 py-1.5 text-xs text-amber-900 dark:border-amber-700/60 dark:bg-amber-950/30 dark:text-amber-100">
|
||||
<CircleAlert className="size-3.5 shrink-0" />
|
||||
<span className="font-semibold">重要:</span>
|
||||
<span>新增或拉取模型后,需要到“模型”Tab 选择可选项才会显示。</span>
|
||||
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold text-amber-900 dark:text-amber-100" onClick={() => setActiveTab("models")}>
|
||||
去模型设置
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={Boolean(loadingChannelId)} onClick={() => void refreshAllModels()}>
|
||||
拉取全部
|
||||
</Button>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||
新增渠道
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-xs text-stone-500">每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。</div>
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||
新增渠道
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
{config.channels.map((channel) => (
|
||||
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||
<div className="mt-1 text-xs text-stone-500">
|
||||
{apiFormatLabel(channel.apiFormat)} · 已保存 {channel.models.length} 个模型
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="small" loading={loadingChannelId === channel.id} onClick={() => void refreshChannelModels(channel)}>
|
||||
拉取模型
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||
<div key={channel.id} className="flex items-center justify-between gap-3 rounded-lg border border-stone-200 px-4 py-3 dark:border-stone-800">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||
<div className="mt-1 truncate text-xs text-stone-500">
|
||||
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} 个模型 · {channel.baseUrl || "未填写接口地址"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Form.Item label="渠道名称" className="mb-0">
|
||||
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="调用格式" className="mb-0">
|
||||
<Select value={channel.apiFormat} options={apiFormatOptions} onChange={(value: ApiCallFormat) => updateChannelApiFormat(channel, value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="Base URL" className="mb-0">
|
||||
<Input value={channel.baseUrl} onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" className="mb-0">
|
||||
<Input.Password value={channel.apiKey} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} />
|
||||
</Form.Item>
|
||||
<Form.Item label="模型列表" className="mb-0 md:col-span-2">
|
||||
<Select mode="tags" showSearch allowClear maxTagCount="responsive" placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models })} />
|
||||
</Form.Item>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
label: "模型",
|
||||
key: "preferences",
|
||||
label: "偏好设置",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
||||
<div className="text-sm font-semibold">默认模型和可选项</div>
|
||||
<div className="mt-1 text-xs leading-5 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="tags"
|
||||
showSearch
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
placeholder={config.models.length ? `请选择或输入${group.optionsLabel}` : "先到渠道里填写或拉取模型"}
|
||||
value={config[group.modelsKey]}
|
||||
options={modelOptions}
|
||||
onChange={(models) => updateCapabilityModels(group, models)}
|
||||
/>
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="mb-2 text-sm font-semibold">默认模型</div>
|
||||
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{modelGroups.map((group) => (
|
||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
|
||||
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||
</Form.Item>
|
||||
))}
|
||||
</div>
|
||||
</Form>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "生成偏好",
|
||||
children: (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="mb-2 text-sm font-semibold">生成偏好</div>
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||
<Input
|
||||
@@ -407,6 +293,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<ChannelEditorDrawer open={Boolean(editingChannel)} channel={editingChannel} onSave={saveChannel} onClose={() => setEditingChannelId("")} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -420,7 +307,7 @@ export function AppConfigModal() {
|
||||
title={
|
||||
<div>
|
||||
<div className="text-lg font-semibold">配置与用户偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、模型选择和同步偏好</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">渠道聚合、默认模型和同步偏好</div>
|
||||
</div>
|
||||
}
|
||||
open={isConfigOpen}
|
||||
@@ -436,48 +323,33 @@ export function AppConfigModal() {
|
||||
}
|
||||
|
||||
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
||||
const models = modelOptionsFromChannels(channels);
|
||||
const imageModels = keepOrSuggest(config.imageModels, filterModelsByCapability(models, "image"), models);
|
||||
const videoModels = keepOrSuggest(config.videoModels, filterModelsByCapability(models, "video"), models);
|
||||
const textModels = keepOrSuggest(config.textModels, filterModelsByCapability(models, "text"), models);
|
||||
const audioModels = keepOrSuggest(config.audioModels, filterModelsByCapability(models, "audio"), models);
|
||||
return {
|
||||
const next: AiConfig = {
|
||||
...config,
|
||||
channels,
|
||||
models,
|
||||
models: modelOptionsFromChannels(channels),
|
||||
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
||||
apiKey: channels[0]?.apiKey || config.apiKey,
|
||||
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
||||
imageModels,
|
||||
videoModels,
|
||||
textModels,
|
||||
audioModels,
|
||||
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
|
||||
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
|
||||
textModel: normalizeDefaultModel(config.textModel, textModels),
|
||||
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
|
||||
};
|
||||
return {
|
||||
...next,
|
||||
imageModel: pickDefaultModel(next, "image", config.imageModel),
|
||||
videoModel: pickDefaultModel(next, "video", config.videoModel),
|
||||
textModel: pickDefaultModel(next, "text", config.textModel),
|
||||
audioModel: pickDefaultModel(next, "audio", config.audioModel),
|
||||
};
|
||||
}
|
||||
|
||||
function keepOrSuggest(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 normalizeDefaultModel(value: string, options: string[]) {
|
||||
if (options.includes(value)) return value;
|
||||
return options[0] || value;
|
||||
function pickDefaultModel(config: AiConfig, capability: ModelCapability, current: string) {
|
||||
const options = selectableModelsByCapability(config, capability);
|
||||
const normalized = normalizeModelOptionValue(current, config.channels);
|
||||
return options.includes(normalized) ? normalized : options[0] || "";
|
||||
}
|
||||
|
||||
function normalizeImageCount(value: string) {
|
||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function apiFormatLabel(apiFormat: ApiCallFormat) {
|
||||
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import { Button, Drawer, Input, Segmented, Select, Space } from "antd";
|
||||
import { ListPlus, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { defaultBaseUrlForApiFormat, guessCapability, normalizeChannelModels, type ApiCallFormat, type ChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import { ModelScriptEditor } from "./model-script-editor";
|
||||
import { ModelSelectModal } from "./model-select-modal";
|
||||
|
||||
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
||||
{ label: "OpenAI", value: "openai" },
|
||||
{ label: "Gemini", value: "gemini" },
|
||||
];
|
||||
|
||||
const capabilityOptions: Array<{ label: string; value: ModelCapability }> = [
|
||||
{ label: "生图", value: "image" },
|
||||
{ label: "视频", value: "video" },
|
||||
{ label: "文本", value: "text" },
|
||||
{ label: "音频", value: "audio" },
|
||||
];
|
||||
|
||||
type ScriptTarget = { name: string; capability: ModelCapability; value: string };
|
||||
|
||||
export function ChannelEditorDrawer({ open, channel, onSave, onClose }: { open: boolean; channel: ModelChannel | null; onSave: (channel: ModelChannel) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState<ModelChannel | null>(channel);
|
||||
const [selectOpen, setSelectOpen] = useState(false);
|
||||
const [scriptTarget, setScriptTarget] = useState<ScriptTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && channel) setDraft(channel);
|
||||
}, [open, channel]);
|
||||
|
||||
if (!draft) return null;
|
||||
|
||||
const patch = (value: Partial<ModelChannel>) => setDraft((current) => (current ? { ...current, ...value } : current));
|
||||
const setModels = (models: ChannelModel[]) => patch({ models });
|
||||
|
||||
const changeApiFormat = (apiFormat: ApiCallFormat) => {
|
||||
const baseUrl = !draft.baseUrl.trim() || draft.baseUrl.trim() === defaultBaseUrlForApiFormat(draft.apiFormat) ? defaultBaseUrlForApiFormat(apiFormat) : draft.baseUrl;
|
||||
patch({ apiFormat, baseUrl });
|
||||
};
|
||||
|
||||
const applySelection = (names: string[]) => {
|
||||
const map = new Map(draft.models.map((model) => [model.name, model]));
|
||||
setModels(names.map((name) => map.get(name) || { name, capability: guessCapability(name) }));
|
||||
};
|
||||
|
||||
const setCapability = (name: string, capability: ModelCapability) => setModels(draft.models.map((model) => (model.name === name ? { ...model, capability } : model)));
|
||||
const setScript = (name: string, script: string) => setModels(draft.models.map((model) => (model.name === name ? { ...model, script: script || undefined } : model)));
|
||||
const removeModel = (name: string) => setModels(draft.models.filter((model) => model.name !== name));
|
||||
|
||||
const save = () => {
|
||||
onSave({ ...draft, name: draft.name.trim() || "未命名渠道", models: normalizeChannelModels(draft.models) });
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={open}
|
||||
width={640}
|
||||
title="编辑渠道"
|
||||
onClose={onClose}
|
||||
styles={{ body: { paddingTop: 16 } }}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={onClose}>取消</Button>
|
||||
<Button type="primary" onClick={save}>
|
||||
保存
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">渠道名称</span>
|
||||
<Input value={draft.name} onChange={(event) => patch({ name: event.target.value })} />
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1 block text-sm font-medium">协议</span>
|
||||
<Select className="w-full" value={draft.apiFormat} options={apiFormatOptions} onChange={changeApiFormat} />
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="mb-1 block text-sm font-medium">接口地址</span>
|
||||
<Input value={draft.baseUrl} onChange={(event) => patch({ baseUrl: event.target.value })} placeholder="https://api.example.com" />
|
||||
</label>
|
||||
<label className="block md:col-span-2">
|
||||
<span className="mb-1 block text-sm font-medium">API Key</span>
|
||||
<Input.Password value={draft.apiKey} onChange={(event) => patch({ apiKey: event.target.value })} placeholder="sk-..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">渠道模型</div>
|
||||
<div className="mt-0.5 text-xs text-stone-500">已选 {draft.models.length} 个;为每个模型指定能力并可自定义调用脚本。</div>
|
||||
</div>
|
||||
<Button type="primary" icon={<ListPlus className="size-4" />} onClick={() => setSelectOpen(true)}>
|
||||
选择模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-lg border border-stone-200 p-2 dark:border-stone-800">
|
||||
{draft.models.length ? (
|
||||
draft.models.map((model) => (
|
||||
<div key={model.name} className="flex flex-wrap items-center gap-3 rounded-md px-2 py-1.5 hover:bg-stone-50 dark:hover:bg-stone-900/40">
|
||||
<span className="min-w-0 flex-1 truncate text-sm" title={model.name}>
|
||||
{model.name}
|
||||
</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Segmented size="small" value={model.capability} options={capabilityOptions} onChange={(value) => setCapability(model.name, value as ModelCapability)} />
|
||||
<Button size="small" type={model.script ? "primary" : "default"} ghost={Boolean(model.script)} onClick={() => setScriptTarget({ name: model.name, capability: model.capability, value: model.script || "" })}>
|
||||
{model.script ? "脚本已设" : "调用脚本"}
|
||||
</Button>
|
||||
<Button size="small" danger type="text" icon={<Trash2 className="size-3.5" />} onClick={() => removeModel(model.name)} />
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="px-2 py-8 text-center text-sm text-stone-500">点击「选择模型」拉取或手动增加模型。</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ModelSelectModal open={selectOpen} channel={draft} selectedNames={draft.models.map((model) => model.name)} onConfirm={applySelection} onClose={() => setSelectOpen(false)} />
|
||||
|
||||
<ModelScriptEditor
|
||||
open={Boolean(scriptTarget)}
|
||||
capability={scriptTarget?.capability || "text"}
|
||||
modelName={scriptTarget?.name || ""}
|
||||
value={scriptTarget?.value || ""}
|
||||
onSave={(script) => scriptTarget && setScript(scriptTarget.name, script)}
|
||||
onClose={() => setScriptTarget(null)}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Button, Input, Modal } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { PLUGIN_TEMPLATES } from "@/services/api/model-plugin";
|
||||
import type { ModelCapability } from "@/stores/use-config-store";
|
||||
|
||||
const capabilityLabels: Record<ModelCapability, string> = { image: "生图", video: "视频", text: "文本", audio: "音频" };
|
||||
|
||||
const variableHints = [
|
||||
"input:本次请求的归一化输入(prompt / references / messages / params / body)",
|
||||
"config:{ baseUrl, apiKey, model, apiFormat, systemPrompt }",
|
||||
"http:{ url(path), post(path, body, opts), get(path, opts) },已绑定当前渠道",
|
||||
"poll(request, extract, { intervalMs, timeoutMs }):轮询直到 extract 返回真值",
|
||||
"sleep(ms)、signal:延时与取消信号;onDelta(text):推送流式文本(文本模型)",
|
||||
];
|
||||
|
||||
export function ModelScriptEditor({ open, capability, modelName, value, onSave, onClose }: { open: boolean; capability: ModelCapability; modelName: string; value: string; onSave: (script: string) => void; onClose: () => void }) {
|
||||
const [draft, setDraft] = useState(value);
|
||||
useEffect(() => {
|
||||
if (open) setDraft(value);
|
||||
}, [open, value]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={
|
||||
<div>
|
||||
<div className="text-base font-semibold">调用脚本 · {modelName}</div>
|
||||
<div className="mt-1 text-xs font-normal text-stone-500">当前能力:{capabilityLabels[capability]}。留空则使用系统默认调用方式。</div>
|
||||
</div>
|
||||
}
|
||||
width={720}
|
||||
centered
|
||||
onCancel={onClose}
|
||||
styles={{ body: { maxHeight: "64vh", overflowY: "auto" } }}
|
||||
footer={[
|
||||
<Button key="template" onClick={() => setDraft(PLUGIN_TEMPLATES[capability])}>
|
||||
插入模板
|
||||
</Button>,
|
||||
<Button key="reset" danger onClick={() => setDraft("")}>
|
||||
恢复默认调用
|
||||
</Button>,
|
||||
<Button key="cancel" onClick={onClose}>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="save"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onSave(draft.trim());
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<div className="mb-3 rounded-md border border-stone-200 bg-stone-50 p-3 text-xs leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900/40 dark:text-stone-300">
|
||||
<div className="mb-1 font-semibold">可用变量(异步函数体,需 return 结果)</div>
|
||||
{variableHints.map((hint) => (
|
||||
<div key={hint}>· {hint}</div>
|
||||
))}
|
||||
</div>
|
||||
<Input.TextArea value={draft} onChange={(event) => setDraft(event.target.value)} autoSize={{ minRows: 12, maxRows: 24 }} spellCheck={false} placeholder="留空使用系统默认调用;点击“插入模板”查看示例。" style={{ fontFamily: "var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)", fontSize: 12 }} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -83,7 +83,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
|
||||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||||
if (capability && config.models.length) return "请先在上方配置可选模型";
|
||||
if (capability && config.models.length) return `请先在渠道里为${label}指定模型`;
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user