67
web/src/lib/model-connection-test.ts
Normal file
67
web/src/lib/model-connection-test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { requestAudioGeneration } from "@/services/api/audio";
|
||||
import { requestGeneration, requestImageQuestion } from "@/services/api/image";
|
||||
import { createVideoGenerationTask } from "@/services/api/video";
|
||||
import { defaultConfig, encodeChannelModel, type ModelCapability, type ModelChannel } from "@/stores/use-config-store";
|
||||
import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
|
||||
export async function testChannelModelConnection(channel: ModelChannel, model: string, capability: ModelCapability, protocol: ModelProtocol) {
|
||||
if (!channel.baseUrl.trim()) throw new Error("请先填写 Base URL");
|
||||
if (!channel.apiKey.trim()) throw new Error("请先填写 API Key");
|
||||
const selectedModel = encodeChannelModel(channel.id, model);
|
||||
const modelCost = channel.modelCosts?.find((item) => item.model === model);
|
||||
const testProtocol = channel.apiFormat === "gemini" && !modelCost?.protocol ? undefined : protocol;
|
||||
const testChannel: ModelChannel = {
|
||||
...channel,
|
||||
models: channel.models.includes(model) ? channel.models : [...channel.models, model],
|
||||
modelCosts: [
|
||||
{
|
||||
model,
|
||||
displayName: modelCost?.displayName,
|
||||
capability,
|
||||
protocol: testProtocol,
|
||||
billingMode: modelCost?.billingMode || "fixed_request",
|
||||
unitPriceMicrocredits: modelCost?.unitPriceMicrocredits || 0,
|
||||
},
|
||||
...(channel.modelCosts || []).filter((item) => item.model !== model),
|
||||
],
|
||||
};
|
||||
const config = {
|
||||
...defaultConfig,
|
||||
channelMode: "local" as const,
|
||||
baseUrl: channel.baseUrl,
|
||||
apiKey: channel.apiKey,
|
||||
apiFormat: channel.apiFormat,
|
||||
channels: [testChannel],
|
||||
model: selectedModel,
|
||||
imageModel: selectedModel,
|
||||
videoModel: selectedModel,
|
||||
textModel: selectedModel,
|
||||
audioModel: selectedModel,
|
||||
models: [selectedModel],
|
||||
imageModels: capability === "image" ? [selectedModel] : [],
|
||||
videoModels: capability === "video" ? [selectedModel] : [],
|
||||
textModels: capability === "text" ? [selectedModel] : [],
|
||||
audioModels: capability === "audio" ? [selectedModel] : [],
|
||||
count: "1",
|
||||
size: capability === "image" ? "1024x1024" : "16:9",
|
||||
videoSeconds: "6",
|
||||
vquality: "720",
|
||||
videoGenerateAudio: "false",
|
||||
};
|
||||
|
||||
switch (capability) {
|
||||
case "text":
|
||||
await requestImageQuestion(config, [{ role: "user", content: "Reply with OK." }], () => undefined);
|
||||
return "文本响应正常";
|
||||
case "image":
|
||||
await requestGeneration(config, "A simple gray circle on a white background.");
|
||||
return "图片生成正常";
|
||||
case "audio":
|
||||
await requestAudioGeneration(config, "Model test.");
|
||||
return "音频生成正常";
|
||||
case "video": {
|
||||
const task = await createVideoGenerationTask(config, "A static gray circle on a white background.");
|
||||
return `视频任务已创建(${task.id})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { SYSTEM_MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, modelProtocolSummary, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
|
||||
@@ -26,6 +26,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [fetching, setFetching] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [capability, setCapability] = useState<ChannelModel["capability"] | "all">("all");
|
||||
@@ -116,6 +117,23 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
};
|
||||
|
||||
const testModel = async () => {
|
||||
const values = await form.validateFields(["modelKey", "capability", "protocol"]);
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminChannelModel(channel.id, {
|
||||
modelKey: values.modelKey.trim(),
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
});
|
||||
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "模型测试失败");
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (item: ChannelModel) => {
|
||||
try {
|
||||
await deleteAdminChannelModel(channel.id, item.id);
|
||||
@@ -216,7 +234,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Button type="primary" block loading={saving} onClick={() => void save()}>{editing ? "保存修改" : "添加模型"}</Button>
|
||||
<div className="mb-2 text-xs text-foreground/45">
|
||||
测试会向上游发起真实请求并可能产生供应商费用{modelCapability === "video" ? ",视频测试可能需要数分钟" : ""}。
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button icon={<FlaskConical className="size-4" />} loading={testing} disabled={saving} onClick={() => void testModel()}>测试模型</Button>
|
||||
<Button type="primary" loading={saving} disabled={testing} onClick={() => void save()}>{editing ? "保存修改" : "添加模型"}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Drawer>
|
||||
</AdminPageFrame>
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { InputNumber, Segmented, Select } from "antd";
|
||||
import { useState } from "react";
|
||||
import { App, Button, InputNumber, Segmented, Select } from "antd";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { testChannelModelConnection } from "@/lib/model-connection-test";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
export function ChannelModelSettings({ channel, onChange }: { channel: ModelChannel; onChange: (costs: ModelCost[]) => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [testingModel, setTestingModel] = useState("");
|
||||
if (!channel.models.length) return null;
|
||||
|
||||
const updateCost = (model: string, patch: Partial<ModelCost>) => {
|
||||
@@ -20,10 +25,22 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
onChange(next.filter((item) => channel.models.includes(item.model)));
|
||||
};
|
||||
|
||||
const testModel = async (model: string, capability: ModelCost["capability"], protocol: ModelProtocol) => {
|
||||
setTestingModel(model);
|
||||
try {
|
||||
const detail = await testChannelModelConnection(channel, model, capability, protocol);
|
||||
message.success(`模型测试通过:${detail}`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "模型测试失败");
|
||||
} finally {
|
||||
setTestingModel("");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-border/70 pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[10px] text-foreground/42">每个模型独立决定请求路径和报文;视频价格仅用于成本记录</div></div>
|
||||
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[10px] text-foreground/42">测试会发起真实请求并可能产生供应商费用;视频价格仅用于成本记录</div></div>
|
||||
<span className="text-[10px] text-foreground/35">{channel.models.length} 个模型</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60 rounded-md border border-border/70">
|
||||
@@ -34,7 +51,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
return (
|
||||
<div key={model} className="grid gap-2 px-2.5 py-2.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px] lg:items-center">
|
||||
<div key={model} className="grid gap-2 px-2.5 py-2.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px_96px] lg:items-center">
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[10px] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<Select<ModelProtocol>
|
||||
size="small"
|
||||
@@ -44,6 +61,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
/>
|
||||
{capability === "video" ? <Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : <span className="text-center text-[10px] text-foreground/35">{modelProtocolLabel(protocol)}</span>}
|
||||
{capability === "video" ? <InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /> : <span />}
|
||||
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -153,7 +153,7 @@ export default function SettingsPage() {
|
||||
}
|
||||
setChannelLoading(channel.id, true);
|
||||
try {
|
||||
const models = await fetchChannelModels(channel);
|
||||
const models = await fetchChannelModels(channel, true);
|
||||
if (!models.length) {
|
||||
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
|
||||
return;
|
||||
@@ -190,7 +190,7 @@ export default function SettingsPage() {
|
||||
const results = await Promise.all(
|
||||
runnable.map(async (channel) => {
|
||||
try {
|
||||
const models = await fetchChannelModels(channel);
|
||||
const models = await fetchChannelModels(channel, true);
|
||||
return { channel, models, error: "" };
|
||||
} catch (error) {
|
||||
return { channel, models: [] as string[], error: error instanceof Error ? error.message : "读取失败" };
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import axios from "axios";
|
||||
|
||||
import { audioMimeType, normalizeAudioFormatValue, normalizeAudioSpeedValue, normalizeAudioVoiceValue } from "@/lib/audio-generation";
|
||||
import { channelRequest } from "@/services/api/custom-channel-relay";
|
||||
import { uploadMediaFile, type UploadedFile } from "@/services/file-storage";
|
||||
import { buildApiUrl, isSystemProxyBaseUrl, resolveModelRequestConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
|
||||
@@ -26,8 +27,9 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
|
||||
const instructions = config.audioInstructions.trim();
|
||||
|
||||
try {
|
||||
const request = channelRequest(requestConfig, aiApiUrl(requestConfig, "/audio/speech"), aiHeaders(requestConfig));
|
||||
const response = await axios.post<Blob>(
|
||||
aiApiUrl(requestConfig, "/audio/speech"),
|
||||
request.url,
|
||||
{
|
||||
model,
|
||||
input: prompt,
|
||||
@@ -36,7 +38,7 @@ export async function requestAudioGeneration(config: AiConfig, prompt: string, o
|
||||
speed: Number(normalizeAudioSpeedValue(config.audioSpeed)),
|
||||
...(instructions ? { instructions } : {}),
|
||||
},
|
||||
{ headers: aiHeaders(requestConfig), responseType: "blob", signal: options?.signal },
|
||||
{ headers: request.headers, withCredentials: request.credentials === "include", responseType: "blob", signal: options?.signal },
|
||||
);
|
||||
await assertAudioBlob(response.data);
|
||||
return response.data.type.startsWith("audio/") ? response.data : new Blob([response.data], { type: audioMimeType(format) });
|
||||
|
||||
@@ -817,12 +817,7 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
};
|
||||
const responseData = isVolcengineArk
|
||||
? await postVolcengineArkImage(requestConfig, payload, options)
|
||||
: (
|
||||
await axios.post<ImageApiResponse>(aiApiUrl(requestConfig, "/images/generations"), payload, {
|
||||
headers: aiHeaders(requestConfig, "application/json"),
|
||||
signal: options?.signal,
|
||||
})
|
||||
).data;
|
||||
: await postChannelJSON<ImageApiResponse>(requestConfig, aiApiUrl(requestConfig, "/images/generations"), payload, options);
|
||||
const images = parseImagePayload(responseData);
|
||||
return images;
|
||||
} catch (error) {
|
||||
@@ -830,6 +825,17 @@ export async function requestGeneration(config: AiConfig, prompt: string, option
|
||||
}
|
||||
}
|
||||
|
||||
async function postChannelJSON<T>(config: ReturnType<typeof resolveModelRequestConfig>, upstreamUrl: string, body: unknown, options?: RequestOptions) {
|
||||
const request = channelRequest(config, upstreamUrl, aiHeaders(config, "application/json"));
|
||||
return (
|
||||
await axios.post<T>(request.url, body, {
|
||||
headers: request.headers,
|
||||
withCredentials: request.credentials === "include",
|
||||
signal: options?.signal,
|
||||
})
|
||||
).data;
|
||||
}
|
||||
|
||||
export async function requestEdit(config: AiConfig, prompt: string, references: ReferenceImage[], mask?: ReferenceImage, options?: RequestOptions) {
|
||||
const selectedModel = config.model || config.imageModel;
|
||||
const requestConfig = resolveModelRequestConfig(config, selectedModel);
|
||||
|
||||
@@ -228,6 +228,10 @@ export function fetchAdminChannelModels(channelId: string) {
|
||||
return request<{ models: string[]; added: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/fetch`));
|
||||
}
|
||||
|
||||
export function testAdminChannelModel(channelId: string, input: Pick<ChannelModel, "modelKey" | "capability" | "protocol">) {
|
||||
return request<{ durationMs: number }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models/test`, input, { timeout: 10 * 60 * 1000 }));
|
||||
}
|
||||
|
||||
export function createAdminChannelModel(channelId: string, input: Omit<ChannelModel, "id" | "channelId" | "priceVersion" | "createdAt" | "updatedAt">) {
|
||||
return request<{ model: ChannelModel }>(api.post(`/admin/channels/${encodeURIComponent(channelId)}/models`, input));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user