mirror of
https://github.com/basketikun/infinite-canvas.git
synced 2026-08-05 17:04:27 +08:00
feat(model): enhance model configuration and script handling for audio and video capabilities
This commit is contained in:
+3
-1
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
+ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。
|
||||||
|
|
||||||
## v0.7.0 - 2026-07-14
|
## v0.7.0 - 2026-07-14
|
||||||
|
|
||||||
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
+ [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。
|
||||||
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
+ [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。
|
||||||
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。
|
+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。
|
||||||
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
+ [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。
|
||||||
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
+ [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。
|
||||||
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
+ [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。
|
||||||
|
|||||||
@@ -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 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 fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||||
const currentModel = node.metadata?.model;
|
const currentModel = node.metadata?.model;
|
||||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||||
? currentModel
|
? currentModel
|
||||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||||
? defaultModel
|
? defaultModel
|
||||||
: fallbackModel;
|
: fallbackModel;
|
||||||
return {
|
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 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 fallbackModel = mode === "image" ? defaultConfig.imageModel : mode === "video" ? defaultConfig.videoModel : mode === "audio" ? defaultConfig.audioModel : defaultConfig.textModel;
|
||||||
const currentModel = node.metadata?.model;
|
const currentModel = node.metadata?.model;
|
||||||
const model = currentModel && modelMatchesCapability(currentModel, mode)
|
const model = currentModel && modelMatchesCapability(globalConfig, currentModel, mode)
|
||||||
? currentModel
|
? currentModel
|
||||||
: defaultModel && modelMatchesCapability(defaultModel, mode)
|
: defaultModel && modelMatchesCapability(globalConfig, defaultModel, mode)
|
||||||
? defaultModel
|
? defaultModel
|
||||||
: fallbackModel;
|
: fallbackModel;
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,20 +1,18 @@
|
|||||||
import { App, Button, Form, Input, Modal, Progress, Select, Tabs } from "antd";
|
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 { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { ModelPicker } from "@/components/model-picker";
|
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 { syncAppDataToWebdav, type AppSyncDomainKey, type AppSyncProgressEvent } from "@/services/app-sync";
|
||||||
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
import { testWebdavConnection, WEBDAV_MANIFEST_FILE_NAME } from "@/services/webdav-sync";
|
||||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
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 = {
|
type ModelGroup = {
|
||||||
capability: ModelCapability;
|
capability: ModelCapability;
|
||||||
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
modelKey: "imageModel" | "videoModel" | "textModel" | "audioModel";
|
||||||
modelsKey: "imageModels" | "videoModels" | "textModels" | "audioModels";
|
|
||||||
defaultLabel: string;
|
defaultLabel: string;
|
||||||
optionsLabel: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type WebdavDomainProgress = {
|
type WebdavDomainProgress = {
|
||||||
@@ -26,15 +24,10 @@ type WebdavDomainProgress = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const modelGroups: ModelGroup[] = [
|
const modelGroups: ModelGroup[] = [
|
||||||
{ capability: "image", modelKey: "imageModel", modelsKey: "imageModels", defaultLabel: "默认生图模型", optionsLabel: "生图模型可选项" },
|
{ capability: "image", modelKey: "imageModel", defaultLabel: "默认生图模型" },
|
||||||
{ capability: "video", modelKey: "videoModel", modelsKey: "videoModels", defaultLabel: "默认视频模型", optionsLabel: "视频模型可选项" },
|
{ capability: "video", modelKey: "videoModel", defaultLabel: "默认视频模型" },
|
||||||
{ capability: "text", modelKey: "textModel", modelsKey: "textModels", defaultLabel: "默认文本模型", optionsLabel: "文本模型可选项" },
|
{ capability: "text", modelKey: "textModel", defaultLabel: "默认文本模型" },
|
||||||
{ capability: "audio", modelKey: "audioModel", modelsKey: "audioModels", defaultLabel: "默认音频模型", optionsLabel: "音频模型可选项" },
|
{ capability: "audio", modelKey: "audioModel", defaultLabel: "默认音频模型" },
|
||||||
];
|
|
||||||
|
|
||||||
const apiFormatOptions: Array<{ label: string; value: ApiCallFormat }> = [
|
|
||||||
{ label: "OpenAI", value: "openai" },
|
|
||||||
{ label: "Gemini", value: "gemini" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const webdavDomainKeys: AppSyncDomainKey[] = ["canvas", "assets", "image-workbench", "video-workbench"];
|
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 }) {
|
export function AppConfigPanel({ showDoneButton = false, initialTab = "channels" }: { showDoneButton?: boolean; initialTab?: ConfigTabKey }) {
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
const [activeTab, setActiveTab] = useState<ConfigTabKey>(initialTab);
|
||||||
const [loadingChannelId, setLoadingChannelId] = useState("");
|
const [editingChannelId, setEditingChannelId] = useState("");
|
||||||
const [testingWebdav, setTestingWebdav] = useState(false);
|
const [testingWebdav, setTestingWebdav] = useState(false);
|
||||||
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
const [syncingWebdav, setSyncingWebdav] = useState(false);
|
||||||
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
const [webdavSyncStatus, setWebdavSyncStatus] = useState("");
|
||||||
@@ -70,8 +63,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
const shouldPromptContinue = useConfigStore((state) => state.shouldPromptContinue);
|
||||||
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
const setConfigDialogOpen = useConfigStore((state) => state.setConfigDialogOpen);
|
||||||
const clearPromptContinue = useConfigStore((state) => state.clearPromptContinue);
|
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 webdavReady = Boolean(webdav.url.trim());
|
||||||
|
const editingChannel = config.channels.find((channel) => channel.id === editingChannelId) || null;
|
||||||
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
useEffect(() => setActiveTab(initialTab), [initialTab]);
|
||||||
|
|
||||||
const saveConfig = (nextConfig: AiConfig) => {
|
const saveConfig = (nextConfig: AiConfig) => {
|
||||||
@@ -86,22 +79,12 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
clearPromptContinue();
|
clearPromptContinue();
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateChannels = (channels: ModelChannel[]) => {
|
const updateChannels = (channels: ModelChannel[]) => saveConfig(withChannels(config, channels));
|
||||||
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 addChannel = () => {
|
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) => {
|
const deleteChannel = (id: string) => {
|
||||||
@@ -112,46 +95,8 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
updateChannels(config.channels.filter((channel) => channel.id !== id));
|
||||||
};
|
};
|
||||||
|
|
||||||
const refreshChannelModels = async (channel: ModelChannel) => {
|
const saveChannel = (channel: ModelChannel) => {
|
||||||
if (!channel.baseUrl.trim() || !channel.apiKey.trim()) {
|
updateChannels(config.channels.map((item) => (item.id === channel.id ? channel : item)));
|
||||||
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 testWebdav = async () => {
|
const testWebdav = async () => {
|
||||||
@@ -215,107 +160,48 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
key: "channels",
|
key: "channels",
|
||||||
label: "渠道",
|
label: "渠道",
|
||||||
children: (
|
children: (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<div>
|
||||||
<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="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="text-xs text-stone-500">每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。</div>
|
||||||
<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">
|
<Button type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||||
<CircleAlert className="size-3.5 shrink-0" />
|
新增渠道
|
||||||
<span className="font-semibold">重要:</span>
|
</Button>
|
||||||
<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>
|
||||||
<div className="space-y-3">
|
<div className="space-y-2">
|
||||||
{config.channels.map((channel) => (
|
{config.channels.map((channel) => (
|
||||||
<section key={channel.id} className="rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<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="mb-3 flex items-center justify-between gap-3">
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
||||||
<div className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</div>
|
<div className="mt-1 truncate text-xs text-stone-500">
|
||||||
<div className="mt-1 text-xs text-stone-500">
|
{apiFormatLabel(channel.apiFormat)} · {channel.models.length} 个模型 · {channel.baseUrl || "未填写接口地址"}
|
||||||
{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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="flex shrink-0 gap-2">
|
||||||
<Form.Item label="渠道名称" className="mb-0">
|
<Button size="small" icon={<Pencil className="size-3.5" />} onClick={() => setEditingChannelId(channel.id)}>
|
||||||
<Input value={channel.name} onChange={(event) => updateChannel(channel.id, { name: event.target.value })} />
|
编辑
|
||||||
</Form.Item>
|
</Button>
|
||||||
<Form.Item label="调用格式" className="mb-0">
|
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => deleteChannel(channel.id)} />
|
||||||
<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>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "models",
|
key: "preferences",
|
||||||
label: "模型",
|
label: "偏好设置",
|
||||||
children: (
|
children: (
|
||||||
<Form layout="vertical" requiredMark={false}>
|
<Form layout="vertical" requiredMark={false}>
|
||||||
<div className="mb-4 rounded-lg border border-stone-200 p-3 dark:border-stone-800">
|
<div className="mb-2 text-sm font-semibold">默认模型</div>
|
||||||
<div className="text-sm font-semibold">默认模型和可选项</div>
|
<div className="mb-4 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<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">
|
|
||||||
{modelGroups.map((group) => (
|
{modelGroups.map((group) => (
|
||||||
<Form.Item key={group.modelKey} label={group.defaultLabel} className="mb-0">
|
<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 />
|
<ModelPicker config={config} value={config[group.modelKey]} onChange={(model) => updateConfig(group.modelKey, model)} capability={group.capability} fullWidth />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
<div className="mb-2 text-sm font-semibold">生成偏好</div>
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "preferences",
|
|
||||||
label: "生成偏好",
|
|
||||||
children: (
|
|
||||||
<Form layout="vertical" requiredMark={false}>
|
|
||||||
<div className="grid gap-4 md:grid-cols-4">
|
<div className="grid gap-4 md:grid-cols-4">
|
||||||
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
<Form.Item label="画布默认生图张数" extra="新建画布生图和配置节点默认使用,单个节点仍可单独覆盖。" className="mb-4">
|
||||||
<Input
|
<Input
|
||||||
@@ -407,6 +293,7 @@ export function AppConfigPanel({ showDoneButton = false, initialTab = "channels"
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<ChannelEditorDrawer open={Boolean(editingChannel)} channel={editingChannel} onSave={saveChannel} onClose={() => setEditingChannelId("")} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -420,7 +307,7 @@ export function AppConfigModal() {
|
|||||||
title={
|
title={
|
||||||
<div>
|
<div>
|
||||||
<div className="text-lg font-semibold">配置与用户偏好</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>
|
</div>
|
||||||
}
|
}
|
||||||
open={isConfigOpen}
|
open={isConfigOpen}
|
||||||
@@ -436,48 +323,33 @@ export function AppConfigModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
||||||
const models = modelOptionsFromChannels(channels);
|
const next: AiConfig = {
|
||||||
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 {
|
|
||||||
...config,
|
...config,
|
||||||
channels,
|
channels,
|
||||||
models,
|
models: modelOptionsFromChannels(channels),
|
||||||
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
||||||
apiKey: channels[0]?.apiKey || config.apiKey,
|
apiKey: channels[0]?.apiKey || config.apiKey,
|
||||||
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
||||||
imageModels,
|
};
|
||||||
videoModels,
|
return {
|
||||||
textModels,
|
...next,
|
||||||
audioModels,
|
imageModel: pickDefaultModel(next, "image", config.imageModel),
|
||||||
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
|
videoModel: pickDefaultModel(next, "video", config.videoModel),
|
||||||
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
|
textModel: pickDefaultModel(next, "text", config.textModel),
|
||||||
textModel: normalizeDefaultModel(config.textModel, textModels),
|
audioModel: pickDefaultModel(next, "audio", config.audioModel),
|
||||||
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function keepOrSuggest(current: string[], suggested: string[], allModels: string[]) {
|
function pickDefaultModel(config: AiConfig, capability: ModelCapability, current: string) {
|
||||||
const available = new Set(allModels);
|
const options = selectableModelsByCapability(config, capability);
|
||||||
const kept = uniqueModels(current).filter((model) => available.has(model));
|
const normalized = normalizeModelOptionValue(current, config.channels);
|
||||||
return kept.length ? kept : suggested;
|
return options.includes(normalized) ? normalized : options[0] || "";
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDefaultModel(value: string, options: string[]) {
|
|
||||||
if (options.includes(value)) return value;
|
|
||||||
return options[0] || value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImageCount(value: string) {
|
function normalizeImageCount(value: string) {
|
||||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || 3))));
|
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) {
|
function apiFormatLabel(apiFormat: ApiCallFormat) {
|
||||||
return apiFormat === "gemini" ? "Gemini" : "OpenAI";
|
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) {
|
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
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}模型` : "请先到配置里添加渠道和模型";
|
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { imageAspectOptions, imageQualityOptions } from "@/components/image-sett
|
|||||||
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel";
|
||||||
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
|
||||||
import { useAssetStore } from "@/stores/use-asset-store";
|
import { useAssetStore } from "@/stores/use-asset-store";
|
||||||
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, useConfigStore } from "@/stores/use-config-store";
|
import { modelOptionLabel, modelOptionName, normalizeModelOptionValue, selectableModelsByCapability, useConfigStore } from "@/stores/use-config-store";
|
||||||
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
import { useWorkbenchAgentStore } from "@/stores/use-workbench-agent-store";
|
||||||
|
|
||||||
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。
|
// 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。
|
||||||
@@ -87,7 +87,7 @@ function getImageConfig() {
|
|||||||
const model = config.imageModel || config.model;
|
const model = config.imageModel || config.model;
|
||||||
return {
|
return {
|
||||||
current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" },
|
current: { model, modelName: modelOptionName(model), quality: config.quality || "auto", size: config.size || "1:1", count: config.count || "1" },
|
||||||
models: config.imageModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
models: selectableModelsByCapability(config, "image").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||||
qualityOptions: imageQualityOptions,
|
qualityOptions: imageQualityOptions,
|
||||||
sizeOptions: imageAspectOptions,
|
sizeOptions: imageAspectOptions,
|
||||||
countRange: { min: 1, max: 15 },
|
countRange: { min: 1, max: 15 },
|
||||||
@@ -135,7 +135,7 @@ function getVideoConfig() {
|
|||||||
generateAudio: config.videoGenerateAudio !== "false",
|
generateAudio: config.videoGenerateAudio !== "false",
|
||||||
watermark: config.videoWatermark === "true",
|
watermark: config.videoWatermark === "true",
|
||||||
},
|
},
|
||||||
models: config.videoModels.map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
models: selectableModelsByCapability(config, "video").map((value) => ({ value, label: modelOptionLabel(config, value) })),
|
||||||
sizeOptions: videoSizeOptions,
|
sizeOptions: videoSizeOptions,
|
||||||
secondsOptions: videoSecondOptions,
|
secondsOptions: videoSecondOptions,
|
||||||
resolutionOptions: videoResolutionOptions,
|
resolutionOptions: videoResolutionOptions,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import axios from "axios";
|
|||||||
|
|
||||||
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
||||||
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||||
import { buildApiUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { runModelPlugin } from "./model-plugin";
|
||||||
|
|
||||||
type RequestOptions = { signal?: AbortSignal };
|
type RequestOptions = { signal?: AbortSignal };
|
||||||
|
|
||||||
@@ -20,8 +21,26 @@ function aiHeaders(config: AiConfig) {
|
|||||||
export async function requestAudioGeneration(config: AiConfig, prompt: string, options?: RequestOptions): Promise<Blob> {
|
export async function requestAudioGeneration(config: AiConfig, prompt: string, options?: RequestOptions): Promise<Blob> {
|
||||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel);
|
const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel);
|
||||||
const model = requestConfig.model.trim();
|
const model = requestConfig.model.trim();
|
||||||
assertAudioConfig(requestConfig, model);
|
|
||||||
const format = normalizeAudioFormatValue(config.audioFormat);
|
const format = normalizeAudioFormatValue(config.audioFormat);
|
||||||
|
const script = resolveModelScript(config, config.model || config.audioModel);
|
||||||
|
if (script) {
|
||||||
|
if (!model) throw new Error("请先配置音频模型");
|
||||||
|
if (!requestConfig.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||||
|
if (!requestConfig.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||||
|
try {
|
||||||
|
const result = await runModelPlugin({
|
||||||
|
capability: "audio",
|
||||||
|
script,
|
||||||
|
config: requestConfig,
|
||||||
|
input: { prompt, params: { voice: normalizeAudioVoiceValue(config.audioVoice), format, speed: normalizeAudioSpeedValue(config.audioSpeed), instructions: config.audioInstructions.trim() } },
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
return await audioPluginBlob(result, format);
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(readAxiosError(error, "音频生成失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertAudioConfig(requestConfig, model);
|
||||||
const instructions = config.audioInstructions.trim();
|
const instructions = config.audioInstructions.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -44,6 +63,20 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function audioPluginBlob(result: unknown, format: string): Promise<Blob> {
|
||||||
|
if (result instanceof Blob) return result.type.startsWith("audio/") ? result : new Blob([result], { type: audioMimeType(format) });
|
||||||
|
let source = "";
|
||||||
|
if (typeof result === "string") source = result;
|
||||||
|
else if (result && typeof result === "object") {
|
||||||
|
const record = result as Record<string, unknown>;
|
||||||
|
source = typeof record.b64_json === "string" ? record.b64_json : typeof record.data === "string" ? record.data : typeof record.url === "string" ? record.url : "";
|
||||||
|
}
|
||||||
|
if (!source) throw new Error("模型调用脚本没有返回音频");
|
||||||
|
const url = source.startsWith("data:") || /^https?:/i.test(source) ? source : `data:${audioMimeType(format)};base64,${source}`;
|
||||||
|
const blob = await (await fetch(url)).blob();
|
||||||
|
return blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||||||
|
}
|
||||||
|
|
||||||
export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<UploadedFile> {
|
export async function storeGeneratedAudio(blob: Blob, format = "mp3"): Promise<UploadedFile> {
|
||||||
const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) });
|
||||||
return uploadMediaFile(audio, "audio");
|
return uploadMediaFile(audio, "audio");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
import { buildApiUrl, resolveModelRequestConfig, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
|
import { buildApiUrl, resolveModelRequestConfig, resolveModelScript, type AiConfig, type ModelChannel } from "@/stores/use-config-store";
|
||||||
|
import { normalizePluginImages, runModelPlugin } from "./model-plugin";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { dataUrlToFile } from "@/lib/image-utils";
|
import { dataUrlToFile } from "@/lib/image-utils";
|
||||||
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
|
import { buildImageReferencePromptText } from "@/lib/image-reference-prompt";
|
||||||
@@ -656,6 +657,28 @@ function parseGeminiImagePayload(payload: GeminiPayload) {
|
|||||||
export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) {
|
export async function requestGeneration(config: AiConfig, prompt: string, options?: RequestOptions) {
|
||||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
|
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
|
||||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||||
|
const script = resolveModelScript(config, config.model || config.imageModel);
|
||||||
|
if (script) {
|
||||||
|
const quality = normalizeQuality(config.quality);
|
||||||
|
const requestSize = resolveRequestSize(quality, config.size);
|
||||||
|
try {
|
||||||
|
const result = await runModelPlugin({
|
||||||
|
capability: "image",
|
||||||
|
script,
|
||||||
|
config: requestConfig,
|
||||||
|
input: {
|
||||||
|
prompt: withSystemPrompt(requestConfig, prompt),
|
||||||
|
references: [],
|
||||||
|
params: { size: requestSize, quality, count: n },
|
||||||
|
body: { model: requestConfig.model, n, ...(quality ? { quality } : {}), ...(requestSize ? { size: requestSize } : {}), response_format: "b64_json", output_format: IMAGE_OUTPUT_FORMAT },
|
||||||
|
},
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(readAxiosError(error, "请求失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (requestConfig.apiFormat === "gemini") {
|
if (requestConfig.apiFormat === "gemini") {
|
||||||
try {
|
try {
|
||||||
return await requestGeminiImages(requestConfig, prompt, [], n, options);
|
return await requestGeminiImages(requestConfig, prompt, [], n, options);
|
||||||
@@ -693,6 +716,29 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
|||||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
|
const requestConfig = resolveModelRequestConfig(config, config.model || config.imageModel);
|
||||||
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
const n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||||
const requestPrompt = buildImageReferencePromptText(prompt, references);
|
const requestPrompt = buildImageReferencePromptText(prompt, references);
|
||||||
|
const script = resolveModelScript(config, config.model || config.imageModel);
|
||||||
|
if (script) {
|
||||||
|
const quality = normalizeQuality(config.quality);
|
||||||
|
const requestSize = resolveRequestSize(quality, config.size);
|
||||||
|
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
|
||||||
|
try {
|
||||||
|
const result = await runModelPlugin({
|
||||||
|
capability: "image",
|
||||||
|
script,
|
||||||
|
config: requestConfig,
|
||||||
|
input: {
|
||||||
|
prompt: withSystemPrompt(requestConfig, requestPrompt),
|
||||||
|
references: refs,
|
||||||
|
params: { size: requestSize, quality, count: n },
|
||||||
|
body: { model: requestConfig.model, n, ...(quality ? { quality } : {}), ...(requestSize ? { size: requestSize } : {}), response_format: "b64_json", output_format: IMAGE_OUTPUT_FORMAT },
|
||||||
|
},
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
return normalizePluginImages(result).map((dataUrl) => ({ id: nanoid(), dataUrl }));
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(readAxiosError(error, "请求失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (requestConfig.apiFormat === "gemini") {
|
if (requestConfig.apiFormat === "gemini") {
|
||||||
if (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑");
|
if (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑");
|
||||||
try {
|
try {
|
||||||
@@ -730,6 +776,24 @@ export async function requestEdit(config: AiConfig, prompt: string, references:
|
|||||||
|
|
||||||
export async function requestImageQuestion(config: AiConfig, messages: AiTextMessage[], onDelta: (text: string) => void, options?: RequestOptions) {
|
export async function requestImageQuestion(config: AiConfig, messages: AiTextMessage[], onDelta: (text: string) => void, options?: RequestOptions) {
|
||||||
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
|
const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel);
|
||||||
|
const script = resolveModelScript(config, config.model || config.textModel);
|
||||||
|
if (script) {
|
||||||
|
try {
|
||||||
|
const answer = await runModelPlugin<string>({
|
||||||
|
capability: "text",
|
||||||
|
script,
|
||||||
|
config: requestConfig,
|
||||||
|
input: { messages: withSystemMessage(requestConfig, messages), body: { model: requestConfig.model } },
|
||||||
|
signal: options?.signal,
|
||||||
|
onDelta,
|
||||||
|
});
|
||||||
|
const text = String(answer ?? "").trim() || "没有返回内容";
|
||||||
|
if (text === "没有返回内容") onDelta(text);
|
||||||
|
return text;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(readAxiosError(error, "请求失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (requestConfig.apiFormat === "gemini") {
|
if (requestConfig.apiFormat === "gemini") {
|
||||||
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
|
const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容";
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import axios from "axios";
|
||||||
|
|
||||||
|
import { buildApiUrl, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||||
|
|
||||||
|
type RequestOptions = { signal?: AbortSignal };
|
||||||
|
|
||||||
|
export type PluginHttpOptions = {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
responseType?: "json" | "blob" | "text" | "arraybuffer";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginHttp = {
|
||||||
|
url: (path: string) => string;
|
||||||
|
post: (path: string, body?: unknown, options?: PluginHttpOptions) => Promise<unknown>;
|
||||||
|
get: (path: string, options?: PluginHttpOptions) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PluginPollOptions = { intervalMs?: number; timeoutMs?: number };
|
||||||
|
|
||||||
|
export type PluginConfigView = {
|
||||||
|
baseUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
model: string;
|
||||||
|
apiFormat: string;
|
||||||
|
systemPrompt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RunPluginArgs = {
|
||||||
|
capability: ModelCapability;
|
||||||
|
script: string;
|
||||||
|
config: AiConfig;
|
||||||
|
input: Record<string, unknown>;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
onDelta?: (text: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function pluginHeaders(config: AiConfig, extra?: Record<string, string>, hasJsonBody = false): Record<string, string> {
|
||||||
|
const headers: Record<string, string> = {};
|
||||||
|
if (config.apiFormat === "gemini") headers["x-goog-api-key"] = config.apiKey;
|
||||||
|
else headers.Authorization = `Bearer ${config.apiKey}`;
|
||||||
|
if (hasJsonBody) headers["Content-Type"] = "application/json";
|
||||||
|
return { ...headers, ...extra };
|
||||||
|
}
|
||||||
|
|
||||||
|
function pluginUrl(config: AiConfig, path: string) {
|
||||||
|
if (/^https?:/i.test(path)) return path;
|
||||||
|
return buildApiUrl(config.baseUrl, path.startsWith("/") ? path : `/${path}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPluginHttp(config: AiConfig, options?: RequestOptions): PluginHttp {
|
||||||
|
const request = async (method: "get" | "post", path: string, body: unknown, opts?: PluginHttpOptions) => {
|
||||||
|
const isForm = typeof FormData !== "undefined" && body instanceof FormData;
|
||||||
|
const response = await axios.request({
|
||||||
|
method,
|
||||||
|
url: pluginUrl(config, path),
|
||||||
|
data: method === "post" ? body : undefined,
|
||||||
|
params: opts?.params,
|
||||||
|
headers: pluginHeaders(config, opts?.headers, method === "post" && !isForm && body !== undefined),
|
||||||
|
responseType: opts?.responseType || "json",
|
||||||
|
signal: options?.signal,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
url: (path) => pluginUrl(config, path),
|
||||||
|
post: (path, body, opts) => request("post", path, body, opts),
|
||||||
|
get: (path, opts) => request("get", path, undefined, opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sleep(ms: number, signal?: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve, reject) => {
|
||||||
|
if (signal?.aborted) {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPoll(signal?: AbortSignal) {
|
||||||
|
return async function poll<T, R>(request: () => Promise<T>, extract: (value: T) => R | null | undefined | false, options?: PluginPollOptions): Promise<R> {
|
||||||
|
const intervalMs = options?.intervalMs ?? 2500;
|
||||||
|
const timeoutMs = options?.timeoutMs ?? 300000;
|
||||||
|
const deadline = performance.now() + timeoutMs;
|
||||||
|
for (;;) {
|
||||||
|
if (signal?.aborted) throw new DOMException("Aborted", "AbortError");
|
||||||
|
const result = extract(await request());
|
||||||
|
if (result !== null && result !== undefined && result !== false) return result;
|
||||||
|
if (performance.now() >= deadline) throw new Error("插件轮询超时,请检查调用脚本或稍后重试");
|
||||||
|
await sleep(intervalMs, signal);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a user-authored model call script. The script body runs as an async function with these locals:
|
||||||
|
* input —— normalized request input for this capability (prompt / references / messages / params / body)
|
||||||
|
* config —— { baseUrl, apiKey, model, apiFormat, systemPrompt }
|
||||||
|
* http —— { url(path), post(path, body, opts), get(path, opts) } bound to the model's channel
|
||||||
|
* poll —— poll(request, extract, { intervalMs, timeoutMs }) resolves with the first truthy extract result
|
||||||
|
* sleep —— sleep(ms)
|
||||||
|
* signal —— AbortSignal for cancellation
|
||||||
|
* onDelta —— (text) => void, push streaming text (text capability only)
|
||||||
|
* The script must `return` the result; each caller normalizes it to its capability's shape.
|
||||||
|
*/
|
||||||
|
export async function runModelPlugin<T = unknown>(args: RunPluginArgs): Promise<T> {
|
||||||
|
const configView: PluginConfigView = {
|
||||||
|
baseUrl: args.config.baseUrl,
|
||||||
|
apiKey: args.config.apiKey,
|
||||||
|
model: args.config.model,
|
||||||
|
apiFormat: args.config.apiFormat,
|
||||||
|
systemPrompt: args.config.systemPrompt,
|
||||||
|
};
|
||||||
|
const http = createPluginHttp(args.config, { signal: args.signal });
|
||||||
|
const poll = createPoll(args.signal);
|
||||||
|
const runner = new Function(
|
||||||
|
"input",
|
||||||
|
"config",
|
||||||
|
"http",
|
||||||
|
"poll",
|
||||||
|
"sleep",
|
||||||
|
"signal",
|
||||||
|
"onDelta",
|
||||||
|
`"use strict"; return (async () => {\n${args.script}\n})();`,
|
||||||
|
) as (input: unknown, config: PluginConfigView, http: PluginHttp, poll: ReturnType<typeof createPoll>, sleep: (ms: number) => Promise<void>, signal: AbortSignal | undefined, onDelta?: (text: string) => void) => Promise<T>;
|
||||||
|
try {
|
||||||
|
return await runner(args.input, configView, http, poll, (ms: number) => sleep(ms, args.signal), args.signal, args.onDelta);
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||||
|
if (axios.isCancel(error)) throw error;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
throw new Error(`模型调用脚本执行失败:${message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PLUGIN_TEMPLATES: Record<ModelCapability, string> = {
|
||||||
|
image: `// 输入:input.prompt / input.references(dataURL[]) / input.body(默认请求体) / input.params
|
||||||
|
// 返回:dataURL 或 URL 字符串,或它们的数组,或 [{ dataUrl }]
|
||||||
|
const data = await http.post("/images/generations", {
|
||||||
|
...input.body,
|
||||||
|
model: config.model,
|
||||||
|
prompt: input.prompt,
|
||||||
|
});
|
||||||
|
return (data.data || []).map((item) => item.b64_json ? \`data:image/png;base64,\${item.b64_json}\` : item.url);`,
|
||||||
|
video: `// 输入:input.prompt / input.references(dataURL[]) / input.params
|
||||||
|
// 返回:{ url } 或 { blob } 或视频 URL 字符串
|
||||||
|
const task = await http.post("/videos", {
|
||||||
|
model: config.model,
|
||||||
|
prompt: input.prompt,
|
||||||
|
seconds: input.params.seconds,
|
||||||
|
});
|
||||||
|
return await poll(
|
||||||
|
() => http.get(\`/videos/\${task.id}\`),
|
||||||
|
(state) => state.status === "completed" ? { url: state.video_url || state.url } : null,
|
||||||
|
{ intervalMs: 2500, timeoutMs: 300000 },
|
||||||
|
);`,
|
||||||
|
audio: `// 输入:input.prompt / input.params(voice/format/speed/instructions)
|
||||||
|
// 返回:Blob,或 base64/dataURL 字符串
|
||||||
|
return await http.post("/audio/speech", {
|
||||||
|
model: config.model,
|
||||||
|
input: input.prompt,
|
||||||
|
voice: input.params.voice,
|
||||||
|
response_format: input.params.format,
|
||||||
|
speed: Number(input.params.speed),
|
||||||
|
}, { responseType: "blob" });`,
|
||||||
|
text: `// 输入:input.messages([{role,content}]) / input.body
|
||||||
|
// 用 onDelta(text) 推送流式文本;返回最终完整文本
|
||||||
|
const data = await http.post("/chat/completions", {
|
||||||
|
model: config.model,
|
||||||
|
messages: input.messages,
|
||||||
|
});
|
||||||
|
const text = data.choices?.[0]?.message?.content || "";
|
||||||
|
onDelta(text);
|
||||||
|
return text;`,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalize whatever an image script returns into the app's generated-image shape. */
|
||||||
|
export function normalizePluginImages(result: unknown): string[] {
|
||||||
|
const items = Array.isArray(result) ? result : [result];
|
||||||
|
const urls = items
|
||||||
|
.map((item) => {
|
||||||
|
if (typeof item === "string") return item;
|
||||||
|
if (item && typeof item === "object") {
|
||||||
|
const record = item as Record<string, unknown>;
|
||||||
|
if (typeof record.dataUrl === "string") return record.dataUrl;
|
||||||
|
if (typeof record.url === "string") return record.url;
|
||||||
|
if (typeof record.b64_json === "string") return `data:image/png;base64,${record.b64_json}`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!urls.length) throw new Error("模型调用脚本没有返回图片");
|
||||||
|
return urls;
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
import { dataUrlToFile } from "@/lib/image-utils";
|
import { dataUrlToFile } from "@/lib/image-utils";
|
||||||
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||||
import { imageToDataUrl } from "@/services/image-storage";
|
import { imageToDataUrl } from "@/services/image-storage";
|
||||||
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
import { boolConfig, buildSeedancePromptText, isSeedanceVideoConfig, normalizeSeedanceDuration, normalizeSeedanceRatio, normalizeSeedanceResolution, seedanceVideoReferenceError, SEEDANCE_REFERENCE_LIMITS } from "@/lib/seedance-video";
|
||||||
import { buildApiUrl, modelOptionName, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
import { buildApiUrl, modelOptionName, resolveModelRequestConfig, resolveModelScript, type AiConfig } from "@/stores/use-config-store";
|
||||||
|
import { runModelPlugin } from "./model-plugin";
|
||||||
import type { ReferenceImage } from "@/types/image";
|
import type { ReferenceImage } from "@/types/image";
|
||||||
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
import type { ReferenceAudio, ReferenceVideo } from "@/types/media";
|
||||||
|
|
||||||
@@ -23,9 +25,12 @@ type ApiEnvelope<T> = T | { code?: number | string; data?: T | null; msg?: strin
|
|||||||
type RequestOptions = { signal?: AbortSignal };
|
type RequestOptions = { signal?: AbortSignal };
|
||||||
|
|
||||||
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
|
export type VideoGenerationResult = { blob?: Blob; url?: string; mimeType?: string };
|
||||||
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance"; model: string };
|
export type VideoGenerationTask = { id: string; provider: "openai" | "seedance" | "plugin"; model: string };
|
||||||
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
|
export type VideoGenerationTaskState = { status: "pending" } | { status: "completed"; result: VideoGenerationResult } | { status: "failed"; error: string };
|
||||||
|
|
||||||
|
/** Results for scripted (plugin) video models, which run their own create+poll in one shot at task creation. */
|
||||||
|
const pluginVideoResults = new Map<string, VideoGenerationResult>();
|
||||||
|
|
||||||
function aiApiUrl(config: AiConfig, path: string) {
|
function aiApiUrl(config: AiConfig, path: string) {
|
||||||
return buildApiUrl(config.baseUrl, path);
|
return buildApiUrl(config.baseUrl, path);
|
||||||
}
|
}
|
||||||
@@ -54,6 +59,8 @@ export async function requestVideoGeneration(config: AiConfig, prompt: string, r
|
|||||||
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = [], options?: RequestOptions): Promise<VideoGenerationTask> {
|
export async function createVideoGenerationTask(config: AiConfig, prompt: string, references: ReferenceImage[] = [], videoReferences: ReferenceVideo[] = [], audioReferences: ReferenceAudio[] = [], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||||
const selectedModel = (config.model || config.videoModel).trim();
|
const selectedModel = (config.model || config.videoModel).trim();
|
||||||
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
||||||
|
const script = resolveModelScript(config, selectedModel);
|
||||||
|
if (script) return createPluginVideoTask(requestConfig, selectedModel, script, prompt, references, options);
|
||||||
assertVideoConfig(requestConfig, requestConfig.model);
|
assertVideoConfig(requestConfig, requestConfig.model);
|
||||||
if (isSeedanceVideoConfig(requestConfig)) {
|
if (isSeedanceVideoConfig(requestConfig)) {
|
||||||
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
return createSeedanceTask(requestConfig, selectedModel, prompt, references, videoReferences, audioReferences, options);
|
||||||
@@ -65,11 +72,56 @@ export async function createVideoGenerationTask(config: AiConfig, prompt: string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
export async function pollVideoGenerationTask(config: AiConfig, task: VideoGenerationTask, options?: RequestOptions): Promise<VideoGenerationTaskState> {
|
||||||
|
if (task.provider === "plugin") {
|
||||||
|
const result = pluginVideoResults.get(task.id);
|
||||||
|
return result ? { status: "completed", result } : { status: "failed", error: "插件视频任务已失效,请重新生成" };
|
||||||
|
}
|
||||||
const requestConfig = resolveModelRequestConfig(config, task.model);
|
const requestConfig = resolveModelRequestConfig(config, task.model);
|
||||||
assertVideoConfig(requestConfig, requestConfig.model);
|
assertVideoConfig(requestConfig, requestConfig.model);
|
||||||
return task.provider === "seedance" ? pollSeedanceTask(requestConfig, task, options) : pollOpenAIVideoTask(requestConfig, task, options);
|
return task.provider === "seedance" ? pollSeedanceTask(requestConfig, task, options) : pollOpenAIVideoTask(requestConfig, task, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createPluginVideoTask(config: AiConfig, model: string, script: string, prompt: string, references: ReferenceImage[], options?: RequestOptions): Promise<VideoGenerationTask> {
|
||||||
|
if (!config.baseUrl.trim()) throw new Error("请先配置 Base URL");
|
||||||
|
if (!config.apiKey.trim()) throw new Error("请先配置 API Key");
|
||||||
|
const refs = await Promise.all(references.map((image) => imageToDataUrl(image)));
|
||||||
|
const result = videoPluginResult(
|
||||||
|
await runModelPlugin({
|
||||||
|
capability: "video",
|
||||||
|
script,
|
||||||
|
config,
|
||||||
|
input: {
|
||||||
|
prompt,
|
||||||
|
references: refs,
|
||||||
|
params: {
|
||||||
|
seconds: normalizeVideoSeconds(config.videoSeconds),
|
||||||
|
size: normalizeVideoSize(config.size),
|
||||||
|
resolution: normalizeVideoResolution(config.vquality),
|
||||||
|
ratio: config.size,
|
||||||
|
generateAudio: boolConfig(config.videoGenerateAudio, true),
|
||||||
|
watermark: boolConfig(config.videoWatermark, false),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
signal: options?.signal,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const id = nanoid();
|
||||||
|
pluginVideoResults.set(id, result);
|
||||||
|
return { id, provider: "plugin", model };
|
||||||
|
}
|
||||||
|
|
||||||
|
function videoPluginResult(result: unknown): VideoGenerationResult {
|
||||||
|
if (result instanceof Blob) return { blob: result };
|
||||||
|
if (typeof result === "string") return { url: result, mimeType: "video/mp4" };
|
||||||
|
if (result && typeof result === "object") {
|
||||||
|
const record = result as Record<string, unknown>;
|
||||||
|
if (record.blob instanceof Blob) return { blob: record.blob };
|
||||||
|
const url = [record.url, record.video_url, record.result_url].find((value) => typeof value === "string" && value) as string | undefined;
|
||||||
|
if (url) return { url, mimeType: "video/mp4" };
|
||||||
|
}
|
||||||
|
throw new Error("模型调用脚本没有返回视频");
|
||||||
|
}
|
||||||
|
|
||||||
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
|
export async function storeGeneratedVideo(result: VideoGenerationResult): Promise<UploadedFile> {
|
||||||
if (result.blob) return uploadMediaFile(result.blob, "video");
|
if (result.blob) return uploadMediaFile(result.blob, "video");
|
||||||
if (result.url) {
|
if (result.url) {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { persist } from "zustand/middleware";
|
|||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
export type ApiCallFormat = "openai" | "gemini";
|
export type ApiCallFormat = "openai" | "gemini";
|
||||||
|
export type ModelCapability = "image" | "video" | "text" | "audio";
|
||||||
|
|
||||||
|
export type ChannelModel = {
|
||||||
|
name: string;
|
||||||
|
capability: ModelCapability;
|
||||||
|
script?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type ModelChannel = {
|
export type ModelChannel = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -11,7 +18,7 @@ export type ModelChannel = {
|
|||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
apiFormat: ApiCallFormat;
|
apiFormat: ApiCallFormat;
|
||||||
models: string[];
|
models: ChannelModel[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AiConfig = {
|
export type AiConfig = {
|
||||||
@@ -35,10 +42,6 @@ export type AiConfig = {
|
|||||||
videoWatermark: string;
|
videoWatermark: string;
|
||||||
systemPrompt: string;
|
systemPrompt: string;
|
||||||
models: string[];
|
models: string[];
|
||||||
imageModels: string[];
|
|
||||||
videoModels: string[];
|
|
||||||
textModels: string[];
|
|
||||||
audioModels: string[];
|
|
||||||
quality: string;
|
quality: string;
|
||||||
size: string;
|
size: string;
|
||||||
count: string;
|
count: string;
|
||||||
@@ -52,10 +55,9 @@ export type WebdavSyncConfig = {
|
|||||||
directory: string;
|
directory: string;
|
||||||
lastSyncedAt: string;
|
lastSyncedAt: string;
|
||||||
};
|
};
|
||||||
export type ConfigTabKey = "channels" | "models" | "preferences" | "webdav";
|
export type ConfigTabKey = "channels" | "preferences" | "webdav";
|
||||||
|
|
||||||
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
|
export const CONFIG_STORE_KEY = "infinite-canvas:ai_config_store";
|
||||||
export type ModelCapability = "image" | "video" | "text" | "audio";
|
|
||||||
const CHANNEL_MODEL_SEPARATOR = "::";
|
const CHANNEL_MODEL_SEPARATOR = "::";
|
||||||
const OPENAI_BASE_URL = "https://api.openai.com";
|
const OPENAI_BASE_URL = "https://api.openai.com";
|
||||||
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
|
const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com";
|
||||||
@@ -72,7 +74,12 @@ export const defaultConfig: AiConfig = {
|
|||||||
baseUrl: OPENAI_BASE_URL,
|
baseUrl: OPENAI_BASE_URL,
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
apiFormat: "openai",
|
apiFormat: "openai",
|
||||||
models: ["gpt-image-2", "grok-imagine-video", "gpt-5.5", "gpt-4o-mini-tts"],
|
models: [
|
||||||
|
{ name: "gpt-image-2", capability: "image" },
|
||||||
|
{ name: "grok-imagine-video", capability: "video" },
|
||||||
|
{ name: "gpt-5.5", capability: "text" },
|
||||||
|
{ name: "gpt-4o-mini-tts", capability: "audio" },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
model: "default::gpt-image-2",
|
model: "default::gpt-image-2",
|
||||||
@@ -90,10 +97,6 @@ export const defaultConfig: AiConfig = {
|
|||||||
videoWatermark: "false",
|
videoWatermark: "false",
|
||||||
systemPrompt: "",
|
systemPrompt: "",
|
||||||
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
|
models: ["default::gpt-image-2", "default::grok-imagine-video", "default::gpt-5.5", "default::gpt-4o-mini-tts"],
|
||||||
imageModels: ["default::gpt-image-2"],
|
|
||||||
videoModels: ["default::grok-imagine-video"],
|
|
||||||
textModels: ["default::gpt-5.5"],
|
|
||||||
audioModels: ["default::gpt-4o-mini-tts"],
|
|
||||||
quality: "auto",
|
quality: "auto",
|
||||||
size: "1:1",
|
size: "1:1",
|
||||||
count: "1",
|
count: "1",
|
||||||
@@ -122,44 +125,44 @@ type ConfigStore = {
|
|||||||
clearPromptContinue: () => void;
|
clearPromptContinue: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function isVideoModelName(model: string) {
|
const VIDEO_KEYWORDS = ["seedance", "video", "sora", "veo", "kling", "wan", "hailuo"];
|
||||||
const value = modelOptionName(model).toLowerCase();
|
const AUDIO_KEYWORDS = ["audio", "tts", "speech", "voice", "music", "sound"];
|
||||||
return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo");
|
const IMAGE_KEYWORDS = ["seedream", "gpt-image", "image", "dall-e", "dalle", "imagen", "flux", "sdxl", "stable-diffusion", "midjourney"];
|
||||||
|
|
||||||
|
/** Best-effort default capability for a freshly fetched model name; user can override in the channel editor. */
|
||||||
|
export function guessCapability(name: string): ModelCapability {
|
||||||
|
const value = name.toLowerCase();
|
||||||
|
if (VIDEO_KEYWORDS.some((keyword) => value.includes(keyword))) return "video";
|
||||||
|
if (AUDIO_KEYWORDS.some((keyword) => value.includes(keyword))) return "audio";
|
||||||
|
if (IMAGE_KEYWORDS.some((keyword) => value.includes(keyword))) return "image";
|
||||||
|
return "text";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImageModelName(model: string) {
|
function findChannelModel(config: AiConfig, value: string): { channel: ModelChannel; model: ChannelModel } | null {
|
||||||
const value = modelOptionName(model).toLowerCase();
|
const decoded = decodeChannelModel(value);
|
||||||
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"));
|
const name = decoded?.model || value;
|
||||||
|
const channel = decoded ? config.channels.find((item) => item.id === decoded.channelId) : config.channels.find((item) => item.models.some((model) => model.name === name));
|
||||||
|
const model = channel?.models.find((item) => item.name === name);
|
||||||
|
return channel && model ? { channel, model } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAudioModelName(model: string) {
|
export function modelCapabilityOf(config: AiConfig, value: string): ModelCapability | undefined {
|
||||||
const value = modelOptionName(model).toLowerCase();
|
return findChannelModel(config, value)?.model.capability;
|
||||||
return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTextModelName(model: string) {
|
export function modelMatchesCapability(config: AiConfig, value: string, capability?: ModelCapability) {
|
||||||
return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function modelMatchesCapability(model: string, capability?: ModelCapability) {
|
|
||||||
if (!capability) return true;
|
if (!capability) return true;
|
||||||
if (capability === "image") return isImageModelName(model);
|
return modelCapabilityOf(config, value) === capability;
|
||||||
if (capability === "video") return isVideoModelName(model);
|
|
||||||
if (capability === "audio") return isAudioModelName(model);
|
|
||||||
return isTextModelName(model);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function filterModelsByCapability(models: string[], capability?: ModelCapability) {
|
|
||||||
return capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
|
export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) {
|
||||||
if (!capability) return config.models;
|
if (!capability) return config.models;
|
||||||
return config[modelListKey(capability)];
|
return config.channels.flatMap((channel) => channel.models.filter((model) => model.capability === capability).map((model) => encodeChannelModel(channel.id, model.name)));
|
||||||
}
|
}
|
||||||
|
|
||||||
function modelListKey(capability: ModelCapability) {
|
/** The user script (if any) attached to a model; empty string means use the system default call. */
|
||||||
return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels";
|
export function resolveModelScript(config: AiConfig, value: string) {
|
||||||
|
return findChannelModel(config, value)?.model.script?.trim() || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAiConfigReady(config: AiConfig, model: string) {
|
function isAiConfigReady(config: AiConfig, model: string) {
|
||||||
@@ -215,7 +218,7 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
channels,
|
channels,
|
||||||
models,
|
models,
|
||||||
imageModel: normalizeModelOptionValue(config.imageModel || config.model, channels),
|
imageModel: normalizeModelOptionValue(config.imageModel || config.model, channels),
|
||||||
videoModel: normalizeModelOptionValue(config.videoModel || "grok-imagine-video", channels),
|
videoModel: normalizeModelOptionValue(config.videoModel, channels),
|
||||||
textModel: normalizeModelOptionValue(config.textModel || config.model, channels),
|
textModel: normalizeModelOptionValue(config.textModel || config.model, channels),
|
||||||
audioModel: normalizeModelOptionValue(config.audioModel || defaultConfig.audioModel, channels),
|
audioModel: normalizeModelOptionValue(config.audioModel || defaultConfig.audioModel, channels),
|
||||||
audioVoice: config.audioVoice || defaultConfig.audioVoice,
|
audioVoice: config.audioVoice || defaultConfig.audioVoice,
|
||||||
@@ -227,10 +230,6 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
videoGenerateAudio: config.videoGenerateAudio || "true",
|
videoGenerateAudio: config.videoGenerateAudio || "true",
|
||||||
videoWatermark: config.videoWatermark || "false",
|
videoWatermark: config.videoWatermark || "false",
|
||||||
canvasImageCount: config.canvasImageCount || "3",
|
canvasImageCount: config.canvasImageCount || "3",
|
||||||
imageModels: Array.isArray(persistedConfig.imageModels) ? normalizeModelList(config.imageModels, channels) : filterModelsByCapability(models, "image"),
|
|
||||||
videoModels: Array.isArray(persistedConfig.videoModels) ? normalizeModelList(config.videoModels, channels) : filterModelsByCapability(models, "video"),
|
|
||||||
textModels: Array.isArray(persistedConfig.textModels) ? normalizeModelList(config.textModels, channels) : filterModelsByCapability(models, "text"),
|
|
||||||
audioModels: Array.isArray(persistedConfig.audioModels) ? normalizeModelList(config.audioModels, channels) : filterModelsByCapability(models, "audio"),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@@ -238,18 +237,26 @@ export const useConfigStore = create<ConfigStore>()(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
function normalizeModelList(models: string[], channels: ModelChannel[]) {
|
|
||||||
const allModelOptions = channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model)));
|
|
||||||
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)))
|
|
||||||
.map((model) => normalizeModelOptionValue(model, channels))
|
|
||||||
.filter((model) => !allModelOptions.length || allModelOptions.includes(model) || !isChannelModelValue(model));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useEffectiveConfig() {
|
export function useEffectiveConfig() {
|
||||||
const config = useConfigStore((state) => state.config);
|
const config = useConfigStore((state) => state.config);
|
||||||
return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
|
return useMemo(() => ({ ...config, channelMode: "local" as const }), [config]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalize a mixed list of raw model names or model objects into deduped ChannelModel entries. */
|
||||||
|
export function normalizeChannelModels(models: Array<string | ChannelModel> | undefined): ChannelModel[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: ChannelModel[] = [];
|
||||||
|
for (const item of models || []) {
|
||||||
|
const name = (typeof item === "string" ? item : item?.name || "").trim();
|
||||||
|
if (!name || seen.has(name)) continue;
|
||||||
|
seen.add(name);
|
||||||
|
const capability = typeof item === "string" ? guessCapability(name) : item.capability || guessCapability(name);
|
||||||
|
const script = typeof item === "string" ? undefined : item.script?.trim() || undefined;
|
||||||
|
result.push({ name, capability, script });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
|
export function createModelChannel(channel?: Partial<ModelChannel>): ModelChannel {
|
||||||
const apiFormat = normalizeApiFormat(channel?.apiFormat);
|
const apiFormat = normalizeApiFormat(channel?.apiFormat);
|
||||||
return {
|
return {
|
||||||
@@ -258,7 +265,7 @@ export function createModelChannel(channel?: Partial<ModelChannel>): ModelChanne
|
|||||||
baseUrl: channel?.baseUrl?.trim() || defaultBaseUrlForApiFormat(apiFormat),
|
baseUrl: channel?.baseUrl?.trim() || defaultBaseUrlForApiFormat(apiFormat),
|
||||||
apiKey: channel?.apiKey || "",
|
apiKey: channel?.apiKey || "",
|
||||||
apiFormat,
|
apiFormat,
|
||||||
models: uniqueRawModels(channel?.models || []),
|
models: normalizeChannelModels(channel?.models),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +295,7 @@ export function modelOptionLabel(config: AiConfig, value: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function modelOptionsFromChannels(channels: ModelChannel[]) {
|
export function modelOptionsFromChannels(channels: ModelChannel[]) {
|
||||||
return uniqueModelOptions(channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model))));
|
return uniqueModelOptions(channels.flatMap((channel) => channel.models.map((model) => encodeChannelModel(channel.id, model.name))));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeModelOptionValue(value: string | undefined, channels: ModelChannel[]) {
|
export function normalizeModelOptionValue(value: string | undefined, channels: ModelChannel[]) {
|
||||||
@@ -297,17 +304,17 @@ export function normalizeModelOptionValue(value: string | undefined, channels: M
|
|||||||
const decoded = decodeChannelModel(model);
|
const decoded = decodeChannelModel(model);
|
||||||
if (decoded) {
|
if (decoded) {
|
||||||
const channel = channels.find((item) => item.id === decoded.channelId);
|
const channel = channels.find((item) => item.id === decoded.channelId);
|
||||||
return channel && channel.models.includes(decoded.model) ? model : "";
|
return channel && channel.models.some((item) => item.name === decoded.model) ? model : "";
|
||||||
}
|
}
|
||||||
const channel = channels.find((item) => item.models.includes(model)) || channels[0];
|
const channel = channels.find((item) => item.models.some((entry) => entry.name === model)) || channels[0];
|
||||||
return channel && channel.models.includes(model) ? encodeChannelModel(channel.id, model) : model;
|
return channel && channel.models.some((item) => item.name === model) ? encodeChannelModel(channel.id, model) : model;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveModelChannel(config: AiConfig, value: string) {
|
export function resolveModelChannel(config: AiConfig, value: string) {
|
||||||
const decoded = decodeChannelModel(value);
|
const decoded = decodeChannelModel(value);
|
||||||
const model = decoded?.model || value;
|
const model = decoded?.model || value;
|
||||||
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.includes(model));
|
const matched = decoded ? config.channels.find((channel) => channel.id === decoded.channelId) : config.channels.find((channel) => channel.models.some((item) => item.name === model));
|
||||||
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) });
|
return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName).map((name) => ({ name, capability: guessCapability(name) })) });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
export function resolveModelRequestConfig(config: AiConfig, value: string) {
|
||||||
@@ -328,7 +335,7 @@ function normalizeChannels(config: AiConfig) {
|
|||||||
...channel,
|
...channel,
|
||||||
id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`),
|
id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`),
|
||||||
name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`),
|
name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`),
|
||||||
models: uniqueRawModels(channel.models || []),
|
models: normalizeChannelModels(channel.models),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (!channels.length) {
|
if (!channels.length) {
|
||||||
@@ -339,18 +346,11 @@ function normalizeChannels(config: AiConfig) {
|
|||||||
baseUrl: config.baseUrl || defaultConfig.baseUrl,
|
baseUrl: config.baseUrl || defaultConfig.baseUrl,
|
||||||
apiKey: config.apiKey || "",
|
apiKey: config.apiKey || "",
|
||||||
apiFormat: config.apiFormat || defaultConfig.apiFormat,
|
apiFormat: config.apiFormat || defaultConfig.apiFormat,
|
||||||
models: uniqueRawModels([
|
models: normalizeChannelModels([config.model, config.imageModel, config.videoModel, config.textModel, config.audioModel].map(modelOptionName)),
|
||||||
...(config.models || []),
|
|
||||||
config.model,
|
|
||||||
config.imageModel,
|
|
||||||
config.videoModel,
|
|
||||||
config.textModel,
|
|
||||||
config.audioModel,
|
|
||||||
]),
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) }));
|
return channels;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
|
export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) {
|
||||||
@@ -361,10 +361,6 @@ function normalizeApiFormat(apiFormat: unknown): ApiCallFormat {
|
|||||||
return apiFormat === "gemini" ? "gemini" : "openai";
|
return apiFormat === "gemini" ? "gemini" : "openai";
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniqueRawModels(models: string[]) {
|
|
||||||
return Array.from(new Set((models || []).map((model) => modelOptionName(model).trim()).filter(Boolean)));
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueModelOptions(models: string[]) {
|
function uniqueModelOptions(models: string[]) {
|
||||||
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)));
|
return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean)));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user