diff --git a/CHANGELOG.md b/CHANGELOG.md index a8480fd..59003cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,13 @@ ## Unreleased ++ [新增] 支持自定义生图/视频接口调用方式以适配不同中转站。 + ## v0.7.0 - 2026-07-14 + [新增] Agent 对话消息改用 streamdown 流式渲染,提升Markdown 内容展示效果。 + [新增] Agent 新增画布、工作台、提示词库和素材等站点级工具。 -+ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容,并保留独立入口按钮。 ++ [新增] Agent 面板改为全站常驻右侧栏,开关时同步推动顶栏和页面内容。 + [新增] Agent 新增 `site_navigate` 工具,支持页面跳转。 + [新增] Agent 对话运行中支持一键停止,中断当前 Codex turn。 + [新增] 画布节点支持统一维护名称字段,默认显示在节点上方,并可直接双击名称编辑。 diff --git a/web/src/components/canvas/canvas-config-node-panel.tsx b/web/src/components/canvas/canvas-config-node-panel.tsx index 8eb2283..4e265bf 100644 --- a/web/src/components/canvas/canvas-config-node-panel.tsx +++ b/web/src/components/canvas/canvas-config-node-panel.tsx @@ -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 { diff --git a/web/src/components/canvas/canvas-node-prompt-panel.tsx b/web/src/components/canvas/canvas-node-prompt-panel.tsx index aff57e2..ad1ee7e 100644 --- a/web/src/components/canvas/canvas-node-prompt-panel.tsx +++ b/web/src/components/canvas/canvas-node-prompt-panel.tsx @@ -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 { diff --git a/web/src/components/layout/app-config-modal.tsx b/web/src/components/layout/app-config-modal.tsx index b52ab5f..45bcc3b 100644 --- a/web/src/components/layout/app-config-modal.tsx +++ b/web/src/components/layout/app-config-modal.tsx @@ -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(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) => { - 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: ( -
-
-
-
- - 重要: - 新增或拉取模型后,需要到“模型”Tab 选择可选项才会显示。 - -
-
-
- - -
+
+
+
每个渠道选择一个协议并拉取模型,为每个模型指定能力(生图/视频/文本/音频),并可自定义调用脚本。
+
-
+
{config.channels.map((channel) => ( -
-
-
-
{channel.name || "未命名渠道"}
-
- {apiFormatLabel(channel.apiFormat)} · 已保存 {channel.models.length} 个模型 -
-
-
- - + + + } + > +
+ + + +
+ +
+
+
渠道模型
+
已选 {draft.models.length} 个;为每个模型指定能力并可自定义调用脚本。
+
+ +
+ +
+ {draft.models.length ? ( + draft.models.map((model) => ( +
+ + {model.name} + +
+ setCapability(model.name, value as ModelCapability)} /> + +
+
+ )) + ) : ( +
点击「选择模型」拉取或手动增加模型。
+ )} +
+ + model.name)} onConfirm={applySelection} onClose={() => setSelectOpen(false)} /> + + scriptTarget && setScript(scriptTarget.name, script)} + onClose={() => setScriptTarget(null)} + /> + + ); +} diff --git a/web/src/components/layout/model-script-editor.tsx b/web/src/components/layout/model-script-editor.tsx new file mode 100644 index 0000000..1aa586e --- /dev/null +++ b/web/src/components/layout/model-script-editor.tsx @@ -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 = { 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 ( + +
调用脚本 · {modelName}
+
当前能力:{capabilityLabels[capability]}。留空则使用系统默认调用方式。
+
+ } + width={720} + centered + onCancel={onClose} + styles={{ body: { maxHeight: "64vh", overflowY: "auto" } }} + footer={[ + , + , + , + , + ]} + > +
+
可用变量(异步函数体,需 return 结果)
+ {variableHints.map((hint) => ( +
· {hint}
+ ))} +
+ 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 }} /> + + ); +} diff --git a/web/src/components/model-picker.tsx b/web/src/components/model-picker.tsx index 1ebf373..b6e2372 100644 --- a/web/src/components/model-picker.tsx +++ b/web/src/components/model-picker.tsx @@ -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}模型` : "请先到配置里添加渠道和模型"; } diff --git a/web/src/lib/agent/agent-site-tools.ts b/web/src/lib/agent/agent-site-tools.ts index 575a04c..6539629 100644 --- a/web/src/lib/agent/agent-site-tools.ts +++ b/web/src/lib/agent/agent-site-tools.ts @@ -6,7 +6,7 @@ import { imageAspectOptions, imageQualityOptions } from "@/components/image-sett import { videoResolutionOptions, videoSecondOptions, videoSizeOptions } from "@/components/video-settings-panel"; import { useCanvasStore } from "@/stores/canvas/use-canvas-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"; // 在网页端执行 Agent 的「站点级」工具(画布列表、工作台生成、提示词搜索、素材增删查等)。 @@ -87,7 +87,7 @@ function getImageConfig() { const model = config.imageModel || config.model; return { 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, sizeOptions: imageAspectOptions, countRange: { min: 1, max: 15 }, @@ -135,7 +135,7 @@ function getVideoConfig() { generateAudio: config.videoGenerateAudio !== "false", 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, secondsOptions: videoSecondOptions, resolutionOptions: videoResolutionOptions, diff --git a/web/src/services/api/audio.ts b/web/src/services/api/audio.ts index d84a547..611c242 100644 --- a/web/src/services/api/audio.ts +++ b/web/src/services/api/audio.ts @@ -2,7 +2,8 @@ import axios from "axios"; import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation"; 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 }; @@ -20,8 +21,26 @@ function aiHeaders(config: AiConfig) { export async function requestAudioGeneration(config: AiConfig, prompt: string, options?: RequestOptions): Promise { const requestConfig = resolveModelRequestConfig(config, config.model || config.audioModel); const model = requestConfig.model.trim(); - assertAudioConfig(requestConfig, model); 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(); try { @@ -44,6 +63,20 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o } } +async function audioPluginBlob(result: unknown, format: string): Promise { + 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; + 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 { const audio = blob.type.startsWith("audio/") ? blob : new Blob([blob], { type: audioMimeType(format) }); return uploadMediaFile(audio, "audio"); diff --git a/web/src/services/api/image.ts b/web/src/services/api/image.ts index 1959759..e072317 100644 --- a/web/src/services/api/image.ts +++ b/web/src/services/api/image.ts @@ -1,6 +1,7 @@ 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 { dataUrlToFile } from "@/lib/image-utils"; 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) { 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 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") { try { 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 n = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1))); 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 (mask) throw new Error("Gemini 调用格式暂不支持蒙版编辑"); 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) { const requestConfig = resolveModelRequestConfig(config, config.model || config.textModel); + const script = resolveModelScript(config, config.model || config.textModel); + if (script) { + try { + const answer = await runModelPlugin({ + 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 { if (requestConfig.apiFormat === "gemini") { const answer = (await requestGeminiStreamingResponse(requestConfig, toGeminiBody(requestConfig, messages), onDelta, options)).content || "没有返回内容"; diff --git a/web/src/services/api/model-plugin.ts b/web/src/services/api/model-plugin.ts new file mode 100644 index 0000000..092e82f --- /dev/null +++ b/web/src/services/api/model-plugin.ts @@ -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; + params?: Record; + responseType?: "json" | "blob" | "text" | "arraybuffer"; +}; + +export type PluginHttp = { + url: (path: string) => string; + post: (path: string, body?: unknown, options?: PluginHttpOptions) => Promise; + get: (path: string, options?: PluginHttpOptions) => Promise; +}; + +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; + signal?: AbortSignal; + onDelta?: (text: string) => void; +}; + +function pluginHeaders(config: AiConfig, extra?: Record, hasJsonBody = false): Record { + const headers: Record = {}; + 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((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(request: () => Promise, extract: (value: T) => R | null | undefined | false, options?: PluginPollOptions): Promise { + 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(args: RunPluginArgs): Promise { + 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, sleep: (ms: number) => Promise, signal: AbortSignal | undefined, onDelta?: (text: string) => void) => Promise; + 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 = { + 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; + 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; +} diff --git a/web/src/services/api/video.ts b/web/src/services/api/video.ts index d99bb78..3ce8e6d 100644 --- a/web/src/services/api/video.ts +++ b/web/src/services/api/video.ts @@ -1,10 +1,12 @@ import axios from "axios"; +import { nanoid } from "nanoid"; import { dataUrlToFile } from "@/lib/image-utils"; import { getMediaBlob, uploadMediaFile, type UploadedFile } from "@/services/file-storage"; import { imageToDataUrl } from "@/services/image-storage"; 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 { ReferenceAudio, ReferenceVideo } from "@/types/media"; @@ -23,9 +25,12 @@ type ApiEnvelope = T | { code?: number | string; data?: T | null; msg?: strin type RequestOptions = { signal?: AbortSignal }; 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 }; +/** Results for scripted (plugin) video models, which run their own create+poll in one shot at task creation. */ +const pluginVideoResults = new Map(); + function aiApiUrl(config: AiConfig, path: string) { 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 { const selectedModel = (config.model || config.videoModel).trim(); const requestConfig = resolveModelRequestConfig(config, selectedModel); + const script = resolveModelScript(config, selectedModel); + if (script) return createPluginVideoTask(requestConfig, selectedModel, script, prompt, references, options); assertVideoConfig(requestConfig, requestConfig.model); if (isSeedanceVideoConfig(requestConfig)) { 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 { + if (task.provider === "plugin") { + const result = pluginVideoResults.get(task.id); + return result ? { status: "completed", result } : { status: "failed", error: "插件视频任务已失效,请重新生成" }; + } const requestConfig = resolveModelRequestConfig(config, task.model); assertVideoConfig(requestConfig, requestConfig.model); 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 { + 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; + 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 { if (result.blob) return uploadMediaFile(result.blob, "video"); if (result.url) { diff --git a/web/src/stores/use-config-store.ts b/web/src/stores/use-config-store.ts index b54cf37..de9d1f4 100644 --- a/web/src/stores/use-config-store.ts +++ b/web/src/stores/use-config-store.ts @@ -4,6 +4,13 @@ import { persist } from "zustand/middleware"; import { nanoid } from "nanoid"; export type ApiCallFormat = "openai" | "gemini"; +export type ModelCapability = "image" | "video" | "text" | "audio"; + +export type ChannelModel = { + name: string; + capability: ModelCapability; + script?: string; +}; export type ModelChannel = { id: string; @@ -11,7 +18,7 @@ export type ModelChannel = { baseUrl: string; apiKey: string; apiFormat: ApiCallFormat; - models: string[]; + models: ChannelModel[]; }; export type AiConfig = { @@ -35,10 +42,6 @@ export type AiConfig = { videoWatermark: string; systemPrompt: string; models: string[]; - imageModels: string[]; - videoModels: string[]; - textModels: string[]; - audioModels: string[]; quality: string; size: string; count: string; @@ -52,10 +55,9 @@ export type WebdavSyncConfig = { directory: 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 type ModelCapability = "image" | "video" | "text" | "audio"; const CHANNEL_MODEL_SEPARATOR = "::"; const OPENAI_BASE_URL = "https://api.openai.com"; const GEMINI_BASE_URL = "https://generativelanguage.googleapis.com"; @@ -72,7 +74,12 @@ export const defaultConfig: AiConfig = { baseUrl: OPENAI_BASE_URL, apiKey: "", 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", @@ -90,10 +97,6 @@ export const defaultConfig: AiConfig = { videoWatermark: "false", systemPrompt: "", 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", size: "1:1", count: "1", @@ -122,44 +125,44 @@ type ConfigStore = { clearPromptContinue: () => void; }; -function isVideoModelName(model: string) { - const value = modelOptionName(model).toLowerCase(); - return value.includes("seedance") || value.includes("video") || value.includes("sora") || value.includes("veo") || value.includes("kling") || value.includes("wan") || value.includes("hailuo"); +const VIDEO_KEYWORDS = ["seedance", "video", "sora", "veo", "kling", "wan", "hailuo"]; +const AUDIO_KEYWORDS = ["audio", "tts", "speech", "voice", "music", "sound"]; +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) { - const value = modelOptionName(model).toLowerCase(); - return !isVideoModelName(model) && !isAudioModelName(model) && (value.includes("seedream") || value.includes("gpt-image") || value.includes("image") || value.includes("dall-e") || value.includes("dalle") || value.includes("imagen") || value.includes("flux") || value.includes("sdxl") || value.includes("stable-diffusion") || value.includes("midjourney")); +function findChannelModel(config: AiConfig, value: string): { channel: ModelChannel; model: ChannelModel } | null { + const decoded = decodeChannelModel(value); + 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) { - const value = modelOptionName(model).toLowerCase(); - return value.includes("audio") || value.includes("tts") || value.includes("speech") || value.includes("voice") || value.includes("music") || value.includes("sound"); +export function modelCapabilityOf(config: AiConfig, value: string): ModelCapability | undefined { + return findChannelModel(config, value)?.model.capability; } -function isTextModelName(model: string) { - return !isImageModelName(model) && !isVideoModelName(model) && !isAudioModelName(model); -} - -export function modelMatchesCapability(model: string, capability?: ModelCapability) { +export function modelMatchesCapability(config: AiConfig, value: string, capability?: ModelCapability) { if (!capability) return true; - if (capability === "image") return isImageModelName(model); - if (capability === "video") return isVideoModelName(model); - if (capability === "audio") return isAudioModelName(model); - return isTextModelName(model); -} - -export function filterModelsByCapability(models: string[], capability?: ModelCapability) { - return capability ? models.filter((model) => modelMatchesCapability(model, capability)) : models; + return modelCapabilityOf(config, value) === capability; } export function selectableModelsByCapability(config: AiConfig, capability?: ModelCapability) { 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) { - return `${capability}Models` as "imageModels" | "videoModels" | "textModels" | "audioModels"; +/** The user script (if any) attached to a model; empty string means use the system default call. */ +export function resolveModelScript(config: AiConfig, value: string) { + return findChannelModel(config, value)?.model.script?.trim() || ""; } function isAiConfigReady(config: AiConfig, model: string) { @@ -215,7 +218,7 @@ export const useConfigStore = create()( channels, models, 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), audioModel: normalizeModelOptionValue(config.audioModel || defaultConfig.audioModel, channels), audioVoice: config.audioVoice || defaultConfig.audioVoice, @@ -227,10 +230,6 @@ export const useConfigStore = create()( videoGenerateAudio: config.videoGenerateAudio || "true", videoWatermark: config.videoWatermark || "false", 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()( ), ); -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() { const config = useConfigStore((state) => state.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 | undefined): ChannelModel[] { + const seen = new Set(); + 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 { const apiFormat = normalizeApiFormat(channel?.apiFormat); return { @@ -258,7 +265,7 @@ export function createModelChannel(channel?: Partial): ModelChanne baseUrl: channel?.baseUrl?.trim() || defaultBaseUrlForApiFormat(apiFormat), apiKey: channel?.apiKey || "", 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[]) { - 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[]) { @@ -297,17 +304,17 @@ export function normalizeModelOptionValue(value: string | undefined, channels: M const decoded = decodeChannelModel(model); if (decoded) { 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]; - return channel && channel.models.includes(model) ? encodeChannelModel(channel.id, model) : model; + const channel = channels.find((item) => item.models.some((entry) => entry.name === model)) || channels[0]; + return channel && channel.models.some((item) => item.name === model) ? encodeChannelModel(channel.id, model) : model; } export function resolveModelChannel(config: AiConfig, value: string) { const decoded = decodeChannelModel(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)); - return matched || config.channels[0] || createModelChannel({ id: "default", name: "默认渠道", baseUrl: config.baseUrl, apiKey: config.apiKey, apiFormat: config.apiFormat, models: config.models.map(modelOptionName) }); + 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).map((name) => ({ name, capability: guessCapability(name) })) }); } export function resolveModelRequestConfig(config: AiConfig, value: string) { @@ -328,7 +335,7 @@ function normalizeChannels(config: AiConfig) { ...channel, id: channel.id || (index === 0 ? "default" : `channel-${index + 1}`), name: channel.name || (index === 0 ? "默认渠道" : `渠道 ${index + 1}`), - models: uniqueRawModels(channel.models || []), + models: normalizeChannelModels(channel.models), }), ); if (!channels.length) { @@ -339,18 +346,11 @@ function normalizeChannels(config: AiConfig) { baseUrl: config.baseUrl || defaultConfig.baseUrl, apiKey: config.apiKey || "", apiFormat: config.apiFormat || defaultConfig.apiFormat, - models: uniqueRawModels([ - ...(config.models || []), - config.model, - config.imageModel, - config.videoModel, - config.textModel, - config.audioModel, - ]), + models: normalizeChannelModels([config.model, config.imageModel, config.videoModel, config.textModel, config.audioModel].map(modelOptionName)), }), ); } - return channels.map((channel) => ({ ...channel, models: uniqueRawModels(channel.models) })); + return channels; } export function defaultBaseUrlForApiFormat(apiFormat: ApiCallFormat) { @@ -361,10 +361,6 @@ function normalizeApiFormat(apiFormat: unknown): ApiCallFormat { 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[]) { return Array.from(new Set((models || []).map((model) => model.trim()).filter(Boolean))); }