refactor(*): 用户工作台 - 统一画布、任务中心与设置页产品化交互
This commit is contained in:
@@ -1786,7 +1786,7 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
|
||||
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef} panelWidth={624}>
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
|
||||
{renderCanvasNodePanel(dialogNode)}
|
||||
</CanvasNodePanelOverlay>
|
||||
) : null}
|
||||
|
||||
334
web/src/pages/settings/channel-settings-pane.tsx
Normal file
334
web/src/pages/settings/channel-settings-pane.tsx
Normal file
@@ -0,0 +1,334 @@
|
||||
import { App, Button, Form, Input, Popconfirm, Segmented, Select, Tooltip } from "antd";
|
||||
import { ChevronDown, ChevronUp, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
|
||||
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
|
||||
import {
|
||||
createModelChannel,
|
||||
defaultBaseUrlForApiFormat,
|
||||
filterModelsByCapability,
|
||||
modelOptionsFromChannels,
|
||||
useConfigStore,
|
||||
type AiConfig,
|
||||
type ModelChannel,
|
||||
} from "@/stores/use-config-store";
|
||||
import { ChannelModelSettings } from "./channel-video-pricing";
|
||||
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
export function ChannelSettingsPane({ onOpenModels }: { onOpenModels: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const replaceConfig = useConfigStore((state) => state.replaceConfig);
|
||||
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
|
||||
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
|
||||
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
|
||||
|
||||
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
|
||||
replaceConfig(withChannels(baseConfig, channels));
|
||||
};
|
||||
|
||||
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
|
||||
updateChannels(config.channels.map((channel) => {
|
||||
if (channel.id !== id) return channel;
|
||||
const models = patch.models ? uniqueModels(patch.models) : channel.models;
|
||||
return {
|
||||
...channel,
|
||||
...patch,
|
||||
models,
|
||||
modelCosts: patch.modelCosts !== undefined ? patch.modelCosts : (patch.models ? channel.modelCosts?.filter((item) => models.includes(item.model)) : channel.modelCosts),
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChannelConnection = (channel: ModelChannel, connection: UserChannelConnection) => {
|
||||
const apiFormat = connection;
|
||||
const defaultBaseUrl = defaultBaseUrlForApiFormat(apiFormat);
|
||||
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
|
||||
// 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。
|
||||
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
|
||||
updateChannels([...config.channels, channel]);
|
||||
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
|
||||
};
|
||||
|
||||
const deleteChannel = (id: string) => {
|
||||
const channel = config.channels.find((item) => item.id === id);
|
||||
if (channel?.scope === "system") {
|
||||
message.warning("系统渠道由管理员维护");
|
||||
return;
|
||||
}
|
||||
updateChannels(config.channels.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const setChannelLoading = (id: string, loading: boolean) => {
|
||||
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
|
||||
};
|
||||
|
||||
const toggleChannelCollapsed = (id: string) => {
|
||||
setCollapsedChannelIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshChannelModels = async (channel: ModelChannel) => {
|
||||
const connectionError = channelConnectionError(channel);
|
||||
if (connectionError) {
|
||||
message.error(`${channel.name || "当前渠道"}:${connectionError}`);
|
||||
return;
|
||||
}
|
||||
setChannelLoading(channel.id, true);
|
||||
try {
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
if (!result.models.length) {
|
||||
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
|
||||
return;
|
||||
}
|
||||
const latestConfig = useConfigStore.getState().config;
|
||||
const latestChannel = latestConfig.channels.find((item) => item.id === channel.id);
|
||||
if (!latestChannel) return;
|
||||
if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) {
|
||||
message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`);
|
||||
return;
|
||||
}
|
||||
updateChannels(
|
||||
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
|
||||
} catch (error) {
|
||||
message.error(channelModelFetchErrorMessage(error));
|
||||
} finally {
|
||||
setChannelLoading(channel.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAllModels = async () => {
|
||||
const runnable = userChannels.filter((channel) => !channelConnectionError(channel));
|
||||
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
|
||||
if (!runnable.length) {
|
||||
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
|
||||
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setChannelLoading("all", true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
runnable.map(async (channel) => {
|
||||
try {
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
return { channel, result, error: "" };
|
||||
} catch (error) {
|
||||
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const latestConfig = useConfigStore.getState().config;
|
||||
const successful = results.filter((item) => {
|
||||
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
|
||||
return Boolean(item.result.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
|
||||
});
|
||||
const stale = results.filter((item) => {
|
||||
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
|
||||
return Boolean(item.result.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
|
||||
});
|
||||
const failed = results.filter((item) => !item.result.models.length);
|
||||
if (successful.length) {
|
||||
const resultMap = new Map(successful.map((item) => [item.channel.id, item.result] as const));
|
||||
updateChannels(
|
||||
latestConfig.channels.map((channel) => {
|
||||
const fetched = resultMap.get(channel.id);
|
||||
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
|
||||
}),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`已更新 ${successful.length} 个渠道的模型`);
|
||||
}
|
||||
const warnings = [
|
||||
...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`),
|
||||
...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`),
|
||||
...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`),
|
||||
];
|
||||
if (warnings.length) message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`);
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动");
|
||||
} finally {
|
||||
setChannelLoading("all", false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="settings-pane-header">
|
||||
<div className="min-w-0">
|
||||
<h2>自定义渠道</h2>
|
||||
<p>渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={onOpenModels}>打开模型选择</Button></p>
|
||||
</div>
|
||||
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
|
||||
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" icon={<RefreshCw className="size-4" />} loading={loadingChannelIds.includes("all")} disabled={loadingChannelIds.some((id) => id !== "all")} onClick={() => void refreshAllModels()}>拉取全部</Button>
|
||||
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>新增渠道</Button>
|
||||
</div>
|
||||
</div>
|
||||
{userChannels.length ? (
|
||||
<div className="settings-channel-list space-y-2">
|
||||
{userChannels.map((channel) => {
|
||||
const collapsed = collapsedChannelIds.has(channel.id);
|
||||
return (
|
||||
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="settings-channel p-2.5 sm:p-3">
|
||||
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
|
||||
<div className="min-w-0 flex-1 basis-52">
|
||||
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">{channel.name || "未命名渠道"}</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-foreground/55">
|
||||
{channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型
|
||||
<ChannelStatus channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
|
||||
<Button className="h-10 sm:h-8" size="small" icon={<RefreshCw className="size-3.5" />} loading={loadingChannelIds.includes(channel.id)} disabled={loadingChannelIds.includes("all")} onClick={() => void refreshChannelModels(channel)}>拉取模型</Button>
|
||||
<Tooltip title={collapsed ? "展开渠道配置" : "收起渠道配置"}>
|
||||
<Button className="size-10 p-0 sm:size-8" size="small" type="text" aria-label={`${collapsed ? "展开" : "收起"}渠道配置 ${channel.name || "未命名渠道"}`} aria-expanded={!collapsed} aria-controls={`channel-${channel.id}-details`} icon={collapsed ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />} onClick={() => toggleChannelCollapsed(channel.id)} />
|
||||
</Tooltip>
|
||||
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
|
||||
<Tooltip title="删除渠道"><Button className="size-10 p-0 sm:size-8" aria-label={`删除渠道 ${channel.name || "未命名渠道"}`} size="small" type="text" danger disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")} icon={<Trash2 className="size-3.5" />} /></Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div id={`channel-${channel.id}-details`} hidden={collapsed}>
|
||||
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
|
||||
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
|
||||
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3"><Input id={`channel-${channel.id}-name`} value={channel.name} placeholder="例如:我的 NewAPI" onChange={(event) => updateChannel(channel.id, { name: event.target.value })} onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })} /></Form.Item>
|
||||
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。"><Segmented<UserChannelConnection> block value={channelConnectionMode(channel)} options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]} onChange={(value) => updateChannelConnection(channel, value)} /></Form.Item>
|
||||
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6"><Input id={`channel-${channel.id}-base-url`} inputMode="url" value={channel.baseUrl} placeholder="填写渠道 Base URL" onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })} onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })} /></Form.Item>
|
||||
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5"><Input.Password id={`channel-${channel.id}-api-key`} autoComplete="new-password" value={channel.apiKey} placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"} onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })} /></Form.Item>
|
||||
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。"><Input.Password id={`channel-${channel.id}-secret-key`} autoComplete="new-password" value={channel.secretKey || ""} placeholder="填写 Secret Key" onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })} onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })} /></Form.Item>
|
||||
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
|
||||
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7"><Select id={`channel-${channel.id}-models`} mode="tags" showSearch allowClear maxTagCount="responsive" tokenSeparators={[",", "\n"]} placeholder="输入模型名,或点击拉取模型" value={channel.models} onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })} /></Form.Item>
|
||||
<div className="lg:col-span-12"><ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} /></div>
|
||||
</div>
|
||||
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : <WorkspaceState icon="settings" compact title="当前没有自定义渠道" description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。" action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增自定义渠道</Button>} />}
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
export function channelValidationError(channel: ModelChannel) {
|
||||
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
}
|
||||
|
||||
export function isChannelReady(channel: ModelChannel) {
|
||||
return !channelValidationError(channel);
|
||||
}
|
||||
|
||||
export function focusInvalidChannelField(channel: ModelChannel) {
|
||||
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid", secretKey: "valid" });
|
||||
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : requiresSecretKey(channel) && !channel.secretKey?.trim() ? "secret-key" : "models";
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(`channel-${channel.id}-${field}`);
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
function ChannelStatus({ channel }: { channel: ModelChannel }) {
|
||||
const error = channelValidationError(channel);
|
||||
return (
|
||||
<span className={`settings-channel-status ${error ? "is-warning" : "is-ready"}`}>
|
||||
<i aria-hidden="true" />
|
||||
{error || "可用"}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
||||
const models = modelOptionsFromChannels(channels);
|
||||
const imageModels = filterModelsByCapability(models, "image", channels);
|
||||
const videoModels = filterModelsByCapability(models, "video", channels);
|
||||
const textModels = filterModelsByCapability(models, "text", channels);
|
||||
const audioModels = filterModelsByCapability(models, "audio", channels);
|
||||
return { ...config, channels, models, baseUrl: channels[0]?.baseUrl || config.baseUrl, apiKey: channels[0]?.apiKey || config.apiKey, apiFormat: channels[0]?.apiFormat || config.apiFormat, imageModels, videoModels, textModels, audioModels, imageModel: normalizeDefaultModel(config.imageModel, imageModels), videoModel: normalizeDefaultModel(config.videoModel, videoModels), textModel: normalizeDefaultModel(config.textModel, textModels), audioModel: normalizeDefaultModel(config.audioModel, audioModels) };
|
||||
}
|
||||
|
||||
function normalizeDefaultModel(value: string, options: string[]) {
|
||||
return options.includes(value) ? value : options[0] || "";
|
||||
}
|
||||
|
||||
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
|
||||
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
|
||||
const next: ChannelModelCost[] = [];
|
||||
for (const item of catalog) {
|
||||
const existing = existingByModel.get(item.id);
|
||||
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
|
||||
if (existing) {
|
||||
const inferredCapability = modelProtocolCapability(inferredProtocol);
|
||||
next.push(inferredProtocol && existing.protocol !== inferredProtocol ? { ...existing, protocol: inferredProtocol, capability: inferredCapability || existing.capability, capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined } : existing);
|
||||
continue;
|
||||
}
|
||||
const catalogProtocol = inferredProtocol || channel.interfaceType;
|
||||
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
|
||||
if (!catalogProtocol || !catalogCapability) continue;
|
||||
next.push({ model: item.id, capability: catalogCapability, protocol: catalogProtocol, billingMode: "fixed_request", unitPriceMicrocredits: 0, capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined });
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function channelModelFetchErrorMessage(error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : "读取模型失败";
|
||||
if (detail.includes("不允许访问本机") || detail.includes("不允许访问保留地址")) return `${detail};可信私网服务需由部署管理员配置 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS`;
|
||||
return `${detail};也可以直接在模型列表中手动输入模型名`;
|
||||
}
|
||||
|
||||
function channelConnectionMode(channel: ModelChannel): UserChannelConnection {
|
||||
return channel.apiFormat === "gemini" ? "gemini" : "openai";
|
||||
}
|
||||
|
||||
function channelConnectionError(channel: ModelChannel) {
|
||||
const baseUrl = channel.baseUrl.trim();
|
||||
if (!baseUrl) return "请填写 Base URL";
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Base URL 只支持 HTTP 或 HTTPS";
|
||||
} catch {
|
||||
return "Base URL 格式不正确";
|
||||
}
|
||||
if (!channel.apiKey.trim()) return "请填写 API Key / Access Key";
|
||||
if (requiresSecretKey(channel) && !channel.secretKey?.trim()) return "当前协议需要填写 Secret Key";
|
||||
return "";
|
||||
}
|
||||
|
||||
function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
function channelProtocolLabel(channel: ModelChannel) {
|
||||
return channelConnectionMode(channel) === "gemini" ? "Gemini 原生" : "OpenAI 兼容";
|
||||
}
|
||||
|
||||
function isKnownDefaultBaseUrl(value: string) {
|
||||
const normalized = value.trim().replace(/\/+$/, "");
|
||||
if (!normalized) return true;
|
||||
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
|
||||
}
|
||||
|
||||
function requiresSecretKey(channel: ModelChannel) {
|
||||
return channel.interfaceType?.startsWith("volcengine-jimeng-") === true || channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
|
||||
}
|
||||
@@ -48,7 +48,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const activeBillingMode = activeModelCost?.billingMode || "fixed_request";
|
||||
|
||||
return (
|
||||
<div className="mt-3 border-t border-border/70 pt-3">
|
||||
<div className="mt-4">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium">模型能力与请求协议</div>
|
||||
@@ -64,8 +64,8 @@ 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="flex min-w-0 items-center gap-3 rounded-md border border-border/70 bg-background/45 px-3 py-2.5">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border/70 bg-muted/35 text-foreground/65">
|
||||
<div key={model} className="flex min-w-0 items-center gap-3 rounded-md bg-surface-active px-3 py-2.5 transition-colors hover:bg-surface-hover">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.045] text-foreground/65">
|
||||
<Settings2 className="size-4" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -110,7 +110,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
>
|
||||
{activeModel && activeCapability && activeProtocol ? (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border/70 bg-muted/20 px-3 py-2.5">
|
||||
<div className="rounded-md bg-surface-active px-3 py-2.5">
|
||||
<div className="text-xs font-medium">模型能力与请求协议</div>
|
||||
<div className="mt-1 text-[var(--fs-tiny)] text-foreground/45">这些设置只影响当前渠道的这个模型,保存后会同步到生成校验。</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Segmented, Select, Tag, Tooltip } from "antd";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { App, Button, Form, Input, InputNumber, Select } from "antd";
|
||||
import { ArrowLeft, Boxes, Cloud, MessageSquareText, RadioTower, SlidersHorizontal } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
|
||||
import { UserOSSSettingsForm } from "@/components/layout/user-oss-settings-form";
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { fetchChannelModels, type ChannelModelCatalogItem } from "@/services/api/image";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, protocolForModelCatalog } from "@/lib/model-protocols";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import {
|
||||
createModelChannel,
|
||||
defaultBaseUrlForApiFormat,
|
||||
defaultConfig,
|
||||
filterModelsByCapability,
|
||||
modelOptionsFromChannels,
|
||||
useConfigStore,
|
||||
type AiConfig,
|
||||
type ModelChannel,
|
||||
} from "@/stores/use-config-store";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { defaultConfig, useConfigStore } from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ChannelModelSettings } from "./channel-video-pricing";
|
||||
import { ChannelSettingsPane, channelValidationError, focusInvalidChannelField, isChannelReady } from "./channel-settings-pane";
|
||||
import { ModelDefaultGrid } from "./model-default-grid";
|
||||
import { PromptPreferencesPane } from "./prompt-preferences-pane";
|
||||
|
||||
@@ -36,32 +22,18 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
|
||||
{ key: "storage", label: "我的对象存储", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
];
|
||||
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
|
||||
function isConfigSection(value: string | null): value is ConfigSectionKey {
|
||||
return configSections.some((section) => section.key === value);
|
||||
}
|
||||
|
||||
function channelModelFetchErrorMessage(error: unknown) {
|
||||
const detail = error instanceof Error ? error.message : "读取模型失败";
|
||||
// 私网地址会在实际生成时继续被 SSRF 防护拦截,不能提示用户靠手填模型绕过。
|
||||
if (detail.includes("不允许访问本机") || detail.includes("不允许访问保留地址")) {
|
||||
return `${detail};可信私网服务需由部署管理员配置 CANVAS_ALLOWED_PRIVATE_UPSTREAM_HOSTS`;
|
||||
}
|
||||
return `${detail};也可以直接在模型列表中手动输入模型名`;
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { message } = App.useApp();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const requestedSection = searchParams.get("section");
|
||||
const [activeTab, setActiveTab] = useState<ConfigSectionKey>(isConfigSection(requestedSection) ? requestedSection : "channels");
|
||||
const [loadingChannelIds, setLoadingChannelIds] = useState<string[]>([]);
|
||||
const [collapsedChannelIds, setCollapsedChannelIds] = useState<Set<string>>(new Set());
|
||||
const config = useConfigStore((state) => state.config);
|
||||
const updateConfig = useConfigStore((state) => state.updateConfig);
|
||||
const replaceConfig = useConfigStore((state) => state.replaceConfig);
|
||||
const shouldPromptContinue = searchParams.get("continue") === "1";
|
||||
const userId = useUserStore((state) => state.user?.id);
|
||||
const userChannels = config.channels.filter((channel) => channel.scope !== "system");
|
||||
@@ -96,8 +68,7 @@ export default function SettingsPage() {
|
||||
focusInvalidChannelField(invalidChannel);
|
||||
return;
|
||||
}
|
||||
const ready = config.channels.some(isChannelReady);
|
||||
if (!ready) {
|
||||
if (!config.channels.some(isChannelReady)) {
|
||||
selectSection("channels");
|
||||
message.error(shouldPromptContinue ? "请先完成至少一个渠道的 Base URL、API Key 和模型配置" : "当前没有可用渠道,请先完成连接信息和模型配置");
|
||||
return;
|
||||
@@ -106,145 +77,95 @@ export default function SettingsPage() {
|
||||
navigate(-1);
|
||||
};
|
||||
|
||||
const updateChannels = (channels: ModelChannel[], baseConfig = config) => {
|
||||
replaceConfig(withChannels(baseConfig, channels));
|
||||
};
|
||||
|
||||
const updateChannel = (id: string, patch: Partial<ModelChannel>) => {
|
||||
updateChannels(config.channels.map((channel) => {
|
||||
if (channel.id !== id) return channel;
|
||||
const models = patch.models ? uniqueModels(patch.models) : channel.models;
|
||||
return {
|
||||
...channel,
|
||||
...patch,
|
||||
models,
|
||||
modelCosts: patch.modelCosts !== undefined ? patch.modelCosts : (patch.models ? channel.modelCosts?.filter((item) => models.includes(item.model)) : channel.modelCosts),
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChannelConnection = (channel: ModelChannel, connection: UserChannelConnection) => {
|
||||
const apiFormat = connection;
|
||||
const defaultBaseUrl = defaultBaseUrlForApiFormat(apiFormat);
|
||||
const baseUrl = isKnownDefaultBaseUrl(channel.baseUrl) ? defaultBaseUrl : channel.baseUrl;
|
||||
// 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。
|
||||
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
const channel = createModelChannel({ name: `渠道 ${userChannels.length + 1}` });
|
||||
updateChannels([...config.channels, channel]);
|
||||
requestAnimationFrame(() => document.getElementById(`channel-${channel.id}-name`)?.focus());
|
||||
};
|
||||
|
||||
const deleteChannel = (id: string) => {
|
||||
const channel = config.channels.find((item) => item.id === id);
|
||||
if (channel?.scope === "system") {
|
||||
message.warning("系统渠道由管理员维护");
|
||||
return;
|
||||
}
|
||||
updateChannels(config.channels.filter((item) => item.id !== id));
|
||||
};
|
||||
|
||||
const setChannelLoading = (id: string, loading: boolean) => {
|
||||
setLoadingChannelIds((items) => (loading ? Array.from(new Set([...items, id])) : items.filter((item) => item !== id)));
|
||||
};
|
||||
|
||||
const toggleChannelCollapsed = (id: string) => {
|
||||
setCollapsedChannelIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const refreshChannelModels = async (channel: ModelChannel) => {
|
||||
const connectionError = channelConnectionError(channel);
|
||||
if (connectionError) {
|
||||
message.error(`${channel.name || "当前渠道"}:${connectionError}`);
|
||||
return;
|
||||
}
|
||||
setChannelLoading(channel.id, true);
|
||||
try {
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
if (!result.models.length) {
|
||||
message.warning(`${channel.name || "当前渠道"}未返回模型,已保留现有手工模型`);
|
||||
return;
|
||||
}
|
||||
const latestConfig = useConfigStore.getState().config;
|
||||
const latestChannel = latestConfig.channels.find((item) => item.id === channel.id);
|
||||
if (!latestChannel) return;
|
||||
if (channelConnectionSignature(latestChannel) !== channelConnectionSignature(channel)) {
|
||||
message.warning(`${latestChannel.name || "当前渠道"}的连接配置已改变,已忽略旧的拉取结果`);
|
||||
return;
|
||||
}
|
||||
updateChannels(
|
||||
latestConfig.channels.map((item) => (item.id === channel.id ? { ...item, models: result.models, modelCosts: mergeFetchedModelCosts(item, result.catalog) } : item)),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`${latestChannel.name || "当前渠道"}模型列表已更新`);
|
||||
} catch (error) {
|
||||
message.error(channelModelFetchErrorMessage(error));
|
||||
} finally {
|
||||
setChannelLoading(channel.id, false);
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAllModels = async () => {
|
||||
const runnable = userChannels.filter((channel) => !channelConnectionError(channel));
|
||||
const skipped = userChannels.filter((channel) => channelConnectionError(channel));
|
||||
if (!runnable.length) {
|
||||
const detail = skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`).join(";");
|
||||
message.error(detail || "没有可拉取的自定义渠道,请先填写有效 Base URL 和 API Key");
|
||||
return;
|
||||
}
|
||||
setChannelLoading("all", true);
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
runnable.map(async (channel) => {
|
||||
try {
|
||||
const result = await fetchChannelModels(channel, true);
|
||||
return { channel, result, error: "" };
|
||||
} catch (error) {
|
||||
return { channel, result: { models: [], catalog: [] }, error: error instanceof Error ? error.message : "读取失败" };
|
||||
}
|
||||
}),
|
||||
);
|
||||
const latestConfig = useConfigStore.getState().config;
|
||||
const successful = results.filter((item) => {
|
||||
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
|
||||
return Boolean(item.result.models.length && latestChannel && channelConnectionSignature(latestChannel) === channelConnectionSignature(item.channel));
|
||||
});
|
||||
const stale = results.filter((item) => {
|
||||
const latestChannel = latestConfig.channels.find((channel) => channel.id === item.channel.id);
|
||||
return Boolean(item.result.models.length && (!latestChannel || channelConnectionSignature(latestChannel) !== channelConnectionSignature(item.channel)));
|
||||
});
|
||||
const failed = results.filter((item) => !item.result.models.length);
|
||||
if (successful.length) {
|
||||
const resultMap = new Map(successful.map((item) => [item.channel.id, item.result] as const));
|
||||
updateChannels(
|
||||
latestConfig.channels.map((channel) => {
|
||||
const fetched = resultMap.get(channel.id);
|
||||
return fetched ? { ...channel, models: fetched.models, modelCosts: mergeFetchedModelCosts(channel, fetched.catalog) } : channel;
|
||||
}),
|
||||
latestConfig,
|
||||
);
|
||||
message.success(`已更新 ${successful.length} 个渠道的模型`);
|
||||
}
|
||||
const warnings = [
|
||||
...failed.map((item) => `${item.channel.name || "未命名渠道"}:${item.error || "未返回模型"}`),
|
||||
...stale.map((item) => `${item.channel.name || "未命名渠道"}:连接配置已改变,已忽略旧结果`),
|
||||
...skipped.map((channel) => `${channel.name || "未命名渠道"}:${channelConnectionError(channel)}`),
|
||||
];
|
||||
if (warnings.length) {
|
||||
message.warning(`${warnings.join(";")}。未更新的渠道已保留原有模型列表`);
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "批量读取模型失败,原有模型列表未改动");
|
||||
} finally {
|
||||
setChannelLoading("all", false);
|
||||
}
|
||||
const panes: Record<ConfigSectionKey, ReactNode> = {
|
||||
channels: <SettingsPane><ChannelSettingsPane onOpenModels={() => selectSection("models")} /></SettingsPane>,
|
||||
models: (
|
||||
<SettingsPane>
|
||||
<div className="settings-pane-header">
|
||||
<div className="min-w-0">
|
||||
<h2>模型选择</h2>
|
||||
<p>按领域选择默认模型;模型能力与请求协议在渠道“模型与能力”中配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
|
||||
</div>
|
||||
</SettingsPane>
|
||||
),
|
||||
preferences: (
|
||||
<SettingsPane>
|
||||
<div className="settings-pane-header">
|
||||
<div className="min-w-0">
|
||||
<h2>生成偏好</h2>
|
||||
<p>画布、视频与音频默认值,节点内仍可单独覆盖。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<section className="settings-preference-block pb-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold">画布生成</h3>
|
||||
<p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p>
|
||||
</div>
|
||||
<Form.Item label="默认生图张数" className="mb-0 max-w-xs">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={15}
|
||||
precision={0}
|
||||
className="w-full"
|
||||
value={Number(config.canvasImageCount)}
|
||||
onChange={(value) => updateConfig("canvasImageCount", normalizeImageCount(String(value ?? defaultConfig.canvasImageCount)))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</section>
|
||||
<section className="settings-preference-block py-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold">音频默认值</h3>
|
||||
<p className="mt-1 text-xs text-foreground/55">用于新建音频节点和未单独设置参数的生成任务。</p>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form.Item label="默认声音" className="mb-0">
|
||||
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="文件格式" className="mb-0">
|
||||
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="语速" className="mb-0">
|
||||
<InputNumber
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
precision={2}
|
||||
className="w-full"
|
||||
value={Number(config.audioSpeed)}
|
||||
onChange={(value) => updateConfig("audioSpeed", normalizeAudioSpeedValue(String(value ?? defaultConfig.audioSpeed)))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-preference-block pt-6">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-semibold">音频指令</h3>
|
||||
<p className="mt-1 text-xs text-foreground/55">在音频节点没有单独填写时使用。</p>
|
||||
</div>
|
||||
<div className="max-w-2xl">
|
||||
<Form.Item label="默认音频指令" className="mb-0">
|
||||
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
</Form>
|
||||
</div>
|
||||
</SettingsPane>
|
||||
),
|
||||
prompts: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
|
||||
storage: (
|
||||
<SettingsPane>
|
||||
<div className="settings-section">
|
||||
<UserOSSSettingsForm />
|
||||
</div>
|
||||
</SettingsPane>
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -252,7 +173,9 @@ export default function SettingsPage() {
|
||||
<header className="settings-topbar shrink-0">
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{shouldPromptContinue ? (
|
||||
<button type="button" className="app-workspace-icon-button shrink-0" onClick={() => navigate(-1)} aria-label="返回创作页面" title="返回创作页面"><ArrowLeft className="size-4" /></button>
|
||||
<button type="button" className="app-workspace-icon-button shrink-0" onClick={() => navigate(-1)} aria-label="返回创作页面" title="返回创作页面">
|
||||
<ArrowLeft className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<h1 className="truncate text-sm font-semibold">设置</h1>
|
||||
</div>
|
||||
@@ -272,281 +195,19 @@ export default function SettingsPage() {
|
||||
aria-current={selected ? "page" : undefined}
|
||||
>
|
||||
<span className={`shrink-0 md:mt-0.5 ${selected ? "text-[var(--workspace-accent)]" : ""}`}>{item.icon}</span>
|
||||
<span className="min-w-0"><span className="block whitespace-nowrap text-sm font-medium">{item.label}</span><span className="mt-1 hidden text-[var(--fs-label)] leading-4 text-current opacity-65 md:block">{item.description}</span></span>
|
||||
<span className="min-w-0">
|
||||
<span className="block whitespace-nowrap text-sm font-medium">{item.label}</span>
|
||||
<span className="mt-1 hidden text-[var(--fs-label)] leading-4 text-current opacity-65 md:block">{item.description}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<section className="settings-content flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<div className="app-workspace-scroll min-h-0 flex-1 overflow-y-auto overscroll-contain px-4 py-4 md:px-6 md:py-5">
|
||||
<div className={`settings-surface-card ${activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}`}>
|
||||
{([
|
||||
{
|
||||
key: "channels",
|
||||
label: "渠道",
|
||||
children: (
|
||||
<SettingsPane>
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<div className="settings-pane-header">
|
||||
<div className="min-w-0">
|
||||
<h2>自定义渠道</h2>
|
||||
<p>渠道只保存连接类型;拉取模型后,请在“模型与能力”中配置协议。<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>打开模型选择</Button></p>
|
||||
</div>
|
||||
<div className="flex w-full gap-2 sm:w-auto sm:shrink-0">
|
||||
<Button
|
||||
className="h-10 flex-1 sm:h-8 sm:flex-none"
|
||||
icon={<RefreshCw className="size-4" />}
|
||||
loading={loadingChannelIds.includes("all")}
|
||||
disabled={loadingChannelIds.some((id) => id !== "all")}
|
||||
onClick={() => void refreshAllModels()}
|
||||
>
|
||||
拉取全部
|
||||
</Button>
|
||||
<Button className="h-10 flex-1 sm:h-8 sm:flex-none" type="primary" icon={<Plus className="size-4" />} onClick={addChannel}>
|
||||
新增渠道
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{userChannels.length ? (
|
||||
<div className="settings-channel-list space-y-2">
|
||||
{userChannels.map((channel) => (
|
||||
<section key={channel.id} aria-labelledby={`channel-${channel.id}-title`} className="rounded-md border border-border bg-background p-2.5 sm:p-3">
|
||||
<div className="mb-2.5 flex flex-wrap items-start justify-between gap-2.5">
|
||||
<div className="min-w-0 flex-1 basis-52">
|
||||
<h3 id={`channel-${channel.id}-title`} className="truncate text-sm font-semibold">
|
||||
{channel.name || "未命名渠道"}
|
||||
</h3>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-foreground/55">
|
||||
{channelProtocolLabel(channel)} · 已保存 {channel.models.length} 个模型
|
||||
<ChannelStatus channel={channel} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex w-full justify-end gap-2 sm:w-auto sm:shrink-0">
|
||||
<Button
|
||||
className="h-10 sm:h-8"
|
||||
size="small"
|
||||
icon={<RefreshCw className="size-3.5" />}
|
||||
loading={loadingChannelIds.includes(channel.id)}
|
||||
disabled={loadingChannelIds.includes("all")}
|
||||
onClick={() => void refreshChannelModels(channel)}
|
||||
>
|
||||
拉取模型
|
||||
</Button>
|
||||
<Tooltip title={collapsedChannelIds.has(channel.id) ? "\u5c55\u5f00\u6e20\u9053\u914d\u7f6e" : "\u6536\u8d77\u6e20\u9053\u914d\u7f6e"}>
|
||||
<Button
|
||||
className="size-10 p-0 sm:size-8"
|
||||
size="small"
|
||||
aria-label={`${collapsedChannelIds.has(channel.id) ? "\u5c55\u5f00" : "\u6536\u8d77"}\u6e20\u9053\u914d\u7f6e ${channel.name || "\u672a\u547d\u540d\u6e20\u9053"}`}
|
||||
aria-expanded={!collapsedChannelIds.has(channel.id)}
|
||||
aria-controls={`channel-${channel.id}-details`}
|
||||
icon={collapsedChannelIds.has(channel.id) ? <ChevronDown className="size-3.5" /> : <ChevronUp className="size-3.5" />}
|
||||
onClick={() => toggleChannelCollapsed(channel.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm title="删除自定义渠道?" description="该渠道关联的模型选择会同时移除。" okText="删除" cancelText="取消" okButtonProps={{ danger: true }} onConfirm={() => deleteChannel(channel.id)}>
|
||||
<Tooltip title="删除渠道">
|
||||
<Button
|
||||
className="size-10 p-0 sm:size-8"
|
||||
aria-label={`删除渠道 ${channel.name || "未命名渠道"}`}
|
||||
size="small"
|
||||
danger
|
||||
disabled={loadingChannelIds.includes(channel.id) || loadingChannelIds.includes("all")}
|
||||
icon={<Trash2 className="size-3.5" />}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
</div>
|
||||
<div id={`channel-${channel.id}-details`} hidden={collapsedChannelIds.has(channel.id)}>
|
||||
<div className="grid gap-x-3 gap-y-2 lg:grid-cols-12">
|
||||
<div className="settings-field-group-label lg:col-span-12">连接信息</div>
|
||||
<Form.Item label="渠道名称" htmlFor={`channel-${channel.id}-name`} className="mb-0 lg:col-span-3">
|
||||
<Input
|
||||
id={`channel-${channel.id}-name`}
|
||||
value={channel.name}
|
||||
placeholder="例如:我的 NewAPI"
|
||||
onChange={(event) => updateChannel(channel.id, { name: event.target.value })}
|
||||
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="渠道连接类型" className="mb-0 lg:col-span-3" extra="仅用于拉取模型目录;模型能力和请求协议在下方统一配置。">
|
||||
<Segmented<UserChannelConnection>
|
||||
block
|
||||
value={channelConnectionMode(channel)}
|
||||
options={[{ label: "OpenAI 兼容", value: "openai" }, { label: "Gemini 原生", value: "gemini" }]}
|
||||
onChange={(value) => updateChannelConnection(channel, value)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Base URL" htmlFor={`channel-${channel.id}-base-url`} className="mb-0 lg:col-span-6">
|
||||
<Input
|
||||
id={`channel-${channel.id}-base-url`}
|
||||
inputMode="url"
|
||||
value={channel.baseUrl}
|
||||
placeholder="填写渠道 Base URL"
|
||||
onChange={(event) => updateChannel(channel.id, { baseUrl: event.target.value })}
|
||||
onBlur={(event) => updateChannel(channel.id, { baseUrl: event.target.value.trim().replace(/\/+$/, "") })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="API Key" htmlFor={`channel-${channel.id}-api-key`} className="mb-0 lg:col-span-5">
|
||||
<Input.Password
|
||||
id={`channel-${channel.id}-api-key`}
|
||||
autoComplete="new-password"
|
||||
value={channel.apiKey}
|
||||
placeholder={channel.apiFormat === "gemini" ? "填写 Gemini API Key" : "填写当前渠道 API Key"}
|
||||
onChange={(event) => updateChannel(channel.id, { apiKey: event.target.value })}
|
||||
onBlur={(event) => updateChannel(channel.id, { apiKey: event.target.value.trim() })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Secret Key(可选)" htmlFor={`channel-${channel.id}-secret-key`} className="mb-0 lg:col-span-5" extra="即梦等 AK/SK 协议需要;其他协议留空。">
|
||||
<Input.Password
|
||||
id={`channel-${channel.id}-secret-key`}
|
||||
autoComplete="new-password"
|
||||
value={channel.secretKey || ""}
|
||||
placeholder="填写 Secret Key"
|
||||
onChange={(event) => updateChannel(channel.id, { secretKey: event.target.value })}
|
||||
onBlur={(event) => updateChannel(channel.id, { secretKey: event.target.value.trim() })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="settings-field-group-label lg:col-span-12">模型与能力</div>
|
||||
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
|
||||
<Select
|
||||
id={`channel-${channel.id}-models`}
|
||||
mode="tags"
|
||||
showSearch
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
tokenSeparators={[",", "\n"]}
|
||||
placeholder="输入模型名,或点击拉取模型"
|
||||
value={channel.models}
|
||||
onChange={(models) => updateChannel(channel.id, { models: uniqueModels(models) })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<div className="lg:col-span-12">
|
||||
<ChannelHeadersEditor value={channel.headers} onChange={(headers) => updateChannel(channel.id, { headers })} />
|
||||
</div>
|
||||
</div>
|
||||
<ChannelModelSettings channel={channel} onChange={(modelCosts) => updateChannel(channel.id, { modelCosts })} />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<WorkspaceState
|
||||
icon="settings"
|
||||
compact
|
||||
title="当前没有自定义渠道"
|
||||
description="管理员配置的系统渠道会出现在模型选择中;也可以添加自己的模型服务。"
|
||||
action={<Button icon={<Plus className="size-4" />} onClick={addChannel}>新增自定义渠道</Button>}
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</SettingsPane>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "models",
|
||||
label: "模型",
|
||||
children: (
|
||||
<SettingsPane>
|
||||
<div className="settings-pane-header">
|
||||
<div className="min-w-0">
|
||||
<h2>模型选择</h2>
|
||||
<p>按领域选择默认模型;模型能力与请求协议在渠道“模型与能力”中配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section-card">
|
||||
<ModelDefaultGrid config={config} onChange={(key, model) => updateConfig(key, model)} />
|
||||
</div>
|
||||
</SettingsPane>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "preferences",
|
||||
label: "生成偏好",
|
||||
children: (
|
||||
<SettingsPane>
|
||||
<div className="settings-pane-header">
|
||||
<div className="min-w-0">
|
||||
<h2>生成偏好</h2>
|
||||
<p>画布、视频与音频默认值,节点内仍可单独覆盖。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-section-card">
|
||||
<Form layout="vertical" requiredMark={false}>
|
||||
<section className="border-b border-border pb-6">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">画布生成</h3><p className="mt-1 text-xs text-foreground/55">设置新建生成任务时使用的初始值,节点内仍可单独覆盖。</p></div>
|
||||
<Form.Item label="默认生图张数" className="mb-0 max-w-xs">
|
||||
<InputNumber
|
||||
min={1}
|
||||
max={15}
|
||||
precision={0}
|
||||
className="w-full"
|
||||
value={Number(config.canvasImageCount)}
|
||||
onChange={(value) => updateConfig("canvasImageCount", normalizeImageCount(String(value ?? defaultConfig.canvasImageCount)))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</section>
|
||||
|
||||
<section className="border-b border-border py-6">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">音频默认值</h3><p className="mt-1 text-xs text-foreground/55">用于新建音频节点和未单独设置参数的生成任务。</p></div>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Form.Item label="默认声音" className="mb-0">
|
||||
<Select value={config.audioVoice} options={audioVoiceOptions} onChange={(value) => updateConfig("audioVoice", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="文件格式" className="mb-0">
|
||||
<Select value={config.audioFormat} options={audioFormatOptions} onChange={(value) => updateConfig("audioFormat", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item label="语速" className="mb-0">
|
||||
<InputNumber
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
precision={2}
|
||||
className="w-full"
|
||||
value={Number(config.audioSpeed)}
|
||||
onChange={(value) => updateConfig("audioSpeed", normalizeAudioSpeedValue(String(value ?? defaultConfig.audioSpeed)))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="pt-6">
|
||||
<div className="mb-4"><h3 className="text-sm font-semibold">音频指令</h3><p className="mt-1 text-xs text-foreground/55">在音频节点没有单独填写时使用。</p></div>
|
||||
<div className="max-w-2xl">
|
||||
<Form.Item label="默认音频指令" className="mb-0">
|
||||
<Input.TextArea rows={5} value={config.audioInstructions} placeholder="例如:自然、温暖、适合旁白。" onChange={(event) => updateConfig("audioInstructions", event.target.value)} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</section>
|
||||
</Form>
|
||||
</div>
|
||||
</SettingsPane>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "prompts",
|
||||
label: "提示词偏好",
|
||||
children: <SettingsPane fill><PromptPreferencesPane /></SettingsPane>,
|
||||
},
|
||||
{
|
||||
key: "storage",
|
||||
label: (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Cloud className="size-4" />
|
||||
我的对象存储
|
||||
</span>
|
||||
),
|
||||
children: (
|
||||
<SettingsPane>
|
||||
<div className="settings-section-card">
|
||||
<UserOSSSettingsForm />
|
||||
</div>
|
||||
</SettingsPane>
|
||||
),
|
||||
},
|
||||
] as Array<{ key: ConfigSectionKey; label: ReactNode; children: ReactNode }>).find((item) => item.key === activeTab)?.children}
|
||||
<div className={`settings-pane-root ${activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}`}>
|
||||
{panes[activeTab]}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -556,148 +217,9 @@ export default function SettingsPage() {
|
||||
}
|
||||
|
||||
function SettingsPane({ children, fill = false }: { children: ReactNode; fill?: boolean }) {
|
||||
return <div className={fill ? "h-full" : undefined}>{children}</div>;
|
||||
}
|
||||
|
||||
function ChannelStatus({ channel }: { channel: ModelChannel }) {
|
||||
const error = channelValidationError(channel);
|
||||
return error ? (
|
||||
<Tag variant="filled" color="warning" className="m-0">
|
||||
{error}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag variant="filled" color="success" icon={<CircleCheck className="size-3" />} className="m-0">
|
||||
可用
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
function withChannels(config: AiConfig, channels: ModelChannel[]): AiConfig {
|
||||
const models = modelOptionsFromChannels(channels);
|
||||
const imageModels = filterModelsByCapability(models, "image", channels);
|
||||
const videoModels = filterModelsByCapability(models, "video", channels);
|
||||
const textModels = filterModelsByCapability(models, "text", channels);
|
||||
const audioModels = filterModelsByCapability(models, "audio", channels);
|
||||
return {
|
||||
...config,
|
||||
channels,
|
||||
models,
|
||||
baseUrl: channels[0]?.baseUrl || config.baseUrl,
|
||||
apiKey: channels[0]?.apiKey || config.apiKey,
|
||||
apiFormat: channels[0]?.apiFormat || config.apiFormat,
|
||||
imageModels,
|
||||
videoModels,
|
||||
textModels,
|
||||
audioModels,
|
||||
imageModel: normalizeDefaultModel(config.imageModel, imageModels),
|
||||
videoModel: normalizeDefaultModel(config.videoModel, videoModels),
|
||||
textModel: normalizeDefaultModel(config.textModel, textModels),
|
||||
audioModel: normalizeDefaultModel(config.audioModel, audioModels),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDefaultModel(value: string, options: string[]) {
|
||||
if (options.includes(value)) return value;
|
||||
return options[0] || "";
|
||||
return <div className={fill ? "settings-pane h-full" : "settings-pane"}>{children}</div>;
|
||||
}
|
||||
|
||||
function normalizeImageCount(value: string) {
|
||||
return String(Math.max(1, Math.min(15, Math.floor(Math.abs(Number(value)) || Number(defaultConfig.canvasImageCount)))));
|
||||
}
|
||||
|
||||
type ChannelModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
|
||||
// 拉取模型目录时按上游 supported_endpoint_types 推导协议和能力;
|
||||
// 保留已有定价,但目录端点类型与旧配置冲突时自动纠正协议,避免模型名猜测和 multipart 错配。
|
||||
function mergeFetchedModelCosts(channel: ModelChannel, catalog: ChannelModelCatalogItem[]): ChannelModelCost[] {
|
||||
const existingByModel = new Map((channel.modelCosts || []).map((cost) => [cost.model, cost]));
|
||||
const next: ChannelModelCost[] = [];
|
||||
for (const item of catalog) {
|
||||
const existing = existingByModel.get(item.id);
|
||||
const inferredProtocol = protocolForModelCatalog(item.supportedEndpointTypes);
|
||||
if (existing) {
|
||||
if (inferredProtocol && existing.protocol !== inferredProtocol) {
|
||||
const inferredCapability = modelProtocolCapability(inferredProtocol);
|
||||
next.push({
|
||||
...existing,
|
||||
protocol: inferredProtocol,
|
||||
capability: inferredCapability || existing.capability,
|
||||
capabilityConfig: inferredCapability === "image" || inferredCapability === "video" ? defaultModelCapabilityConfig(inferredProtocol, item.id) : undefined,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
next.push(existing);
|
||||
continue;
|
||||
}
|
||||
const catalogProtocol = inferredProtocol || channel.interfaceType;
|
||||
const catalogCapability = catalogProtocol ? modelProtocolCapability(catalogProtocol) : undefined;
|
||||
if (!catalogProtocol || !catalogCapability) continue;
|
||||
next.push({
|
||||
model: item.id,
|
||||
capability: catalogCapability,
|
||||
protocol: catalogProtocol,
|
||||
billingMode: "fixed_request",
|
||||
unitPriceMicrocredits: 0,
|
||||
capabilityConfig: catalogCapability === "image" || catalogCapability === "video" ? defaultModelCapabilityConfig(catalogProtocol, item.id) : undefined,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function channelConnectionMode(channel: ModelChannel): UserChannelConnection {
|
||||
return channel.apiFormat === "gemini" ? "gemini" : "openai";
|
||||
}
|
||||
|
||||
function channelConnectionError(channel: ModelChannel) {
|
||||
const baseUrl = channel.baseUrl.trim();
|
||||
if (!baseUrl) return "请填写 Base URL";
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Base URL 只支持 HTTP 或 HTTPS";
|
||||
} catch {
|
||||
return "Base URL 格式不正确";
|
||||
}
|
||||
if (!channel.apiKey.trim()) return "请填写 API Key / Access Key";
|
||||
if (requiresSecretKey(channel) && !channel.secretKey?.trim()) return "当前协议需要填写 Secret Key";
|
||||
return "";
|
||||
}
|
||||
|
||||
function channelConnectionSignature(channel: ModelChannel) {
|
||||
return [channel.baseUrl.trim(), channel.apiKey.trim(), channel.secretKey?.trim() || "", channel.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
}
|
||||
|
||||
function channelValidationError(channel: ModelChannel) {
|
||||
return channelConnectionError(channel) || validateChannelHeaders(channel.headers) || (!channel.models.length ? "请添加至少一个模型" : "");
|
||||
}
|
||||
|
||||
function isChannelReady(channel: ModelChannel) {
|
||||
return !channelValidationError(channel);
|
||||
}
|
||||
|
||||
function focusInvalidChannelField(channel: ModelChannel) {
|
||||
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid", secretKey: "valid" });
|
||||
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : requiresSecretKey(channel) && !channel.secretKey?.trim() ? "secret-key" : "models";
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.getElementById(`channel-${channel.id}-${field}`);
|
||||
element?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
element?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
function channelProtocolLabel(channel: ModelChannel) {
|
||||
return channelConnectionMode(channel) === "gemini" ? "Gemini 原生" : "OpenAI 兼容";
|
||||
}
|
||||
|
||||
function isKnownDefaultBaseUrl(value: string) {
|
||||
const normalized = value.trim().replace(/\/+$/, "");
|
||||
if (!normalized) return true;
|
||||
return [defaultBaseUrlForApiFormat("openai"), defaultBaseUrlForApiFormat("gemini")].some((candidate) => candidate.replace(/\/+$/, "") === normalized);
|
||||
}
|
||||
|
||||
function requiresSecretKey(channel: ModelChannel) {
|
||||
return channel.interfaceType?.startsWith("volcengine-jimeng-") === true || channel.modelCosts?.some((item) => item.protocol?.startsWith("volcengine-jimeng-")) === true;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Tag } from "antd";
|
||||
import { AudioLines, Check, Film, Image, MessageSquareText } from "lucide-react";
|
||||
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
@@ -31,14 +30,14 @@ const groups: Array<{
|
||||
export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onChange: (key: DefaultModelKey, model: string) => void }) {
|
||||
const creditsEnabled = useUserStore((state) => state.features.creditsEnabled);
|
||||
return (
|
||||
<div className="divide-y divide-border/70">
|
||||
<div className="space-y-1">
|
||||
{groups.map((group) => {
|
||||
const models = filterModelsByCapability(config.models, group.capability, config.channels);
|
||||
const Icon = group.icon;
|
||||
return (
|
||||
<section key={group.capability} className="py-5 first:pt-0 last:pb-0" aria-labelledby={`default-${group.capability}-title`}>
|
||||
<div className="mb-3 flex items-start gap-3">
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md border border-border bg-muted/35 text-foreground/65"><Icon className="size-4" /></span>
|
||||
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-surface-active text-foreground/65"><Icon className="size-4" /></span>
|
||||
<div className="min-w-0">
|
||||
<h3 id={`default-${group.capability}-title`} className="text-sm font-semibold">{group.title}</h3>
|
||||
<p className="mt-0.5 text-xs text-foreground/48">{group.description}</p>
|
||||
@@ -58,26 +57,24 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"group relative min-h-[76px] overflow-hidden rounded-md border px-3 py-2.5 text-left transition-[border-color,background-color,box-shadow] duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
||||
selected
|
||||
? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)] shadow-[inset_0_1px_0_color-mix(in_srgb,var(--workspace-accent)_28%,transparent)]"
|
||||
: "border-border/85 bg-background hover:border-foreground/25 hover:bg-muted/25",
|
||||
"model-default-option group relative overflow-hidden rounded-md px-3 py-2.5 text-left transition-colors duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
|
||||
selected && "is-selected",
|
||||
)}
|
||||
onClick={() => onChange(group.modelKey, model)}
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2.5">
|
||||
<span className={cn("grid size-8 shrink-0 place-items-center rounded-md border", selected ? "border-[color-mix(in_srgb,var(--workspace-accent)_28%,transparent)] bg-background/70" : "border-border/70 bg-muted/35")}>
|
||||
<span className="model-default-option-icon grid size-8 shrink-0 place-items-center rounded-md">
|
||||
<ModelIcon model={model} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-semibold">{modelDisplayName(config, model)}</span>
|
||||
<span className="mt-1 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-1 text-[var(--fs-tiny)] text-foreground/45">
|
||||
<span className="max-w-full truncate">{channel.name || "未命名渠道"}</span>
|
||||
<span className="rounded border border-border/70 px-1 py-px text-[var(--fs-micro)]">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{creditsEnabled && cost ? <Tag color="gold" className="!m-0 !px-1.5 !text-[var(--fs-micro)] !font-medium !leading-[18px] tabular-nums">{formatPrice(cost.unitPriceMicrocredits)} 积分/{cost.billingMode === "per_second" ? "秒" : "次"}</Tag> : null}
|
||||
<span className="model-default-option-scope">{channel.scope === "system" ? "系统" : "自定义"}</span>
|
||||
{creditsEnabled && cost ? <span className="model-default-price">{formatPrice(cost.unitPriceMicrocredits)} /{cost.billingMode === "per_second" ? "秒" : "次"}</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn("grid size-5 shrink-0 place-items-center rounded-full border", selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent)] text-white" : "border-border text-transparent group-hover:border-foreground/30")}>
|
||||
<span className={cn("model-default-option-check grid size-5 shrink-0 place-items-center rounded-full", selected ? "is-selected" : "text-transparent")}>
|
||||
<Check className="size-3" strokeWidth={2.5} />
|
||||
</span>
|
||||
</span>
|
||||
@@ -86,7 +83,7 @@ export function ModelDefaultGrid({ config, onChange }: { config: AiConfig; onCha
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-dashed border-border px-4 py-6 text-center text-xs text-foreground/45">当前渠道中没有可用的{capabilityLabel(group.capability)}模型</div>
|
||||
<div className="rounded-md bg-surface-active px-4 py-6 text-center text-xs text-foreground/45">当前渠道中没有可用的{capabilityLabel(group.capability)}模型</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Switch, Tooltip, Typography } from "antd";
|
||||
import { App, Button, Drawer, Form, Input, Modal, Select, Switch, Tooltip, Typography } from "antd";
|
||||
import { LayoutGrid, List, Plus, RefreshCw, Search } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
@@ -19,7 +19,7 @@ import { TaskGridCard } from "./task-grid-card";
|
||||
import { TaskGroupHeader, type TaskGroup } from "./task-group-header";
|
||||
import { TaskListRow } from "./task-list-row";
|
||||
import { formatModelName, getTaskCanvasContext, isTaskFailed, providerCancelStatusLabel, taskMediaKind } from "./task-shared";
|
||||
import { TaskStatPills, type TaskStatusFilter } from "./task-stat-pills";
|
||||
import { TaskStatusFilterBar, type TaskStatusFilter } from "./task-status-filter";
|
||||
|
||||
type TaskKindFilter = "all" | "text" | "image" | "video";
|
||||
type TaskViewMode = "list" | "grid";
|
||||
@@ -125,7 +125,7 @@ export default function TasksPage() {
|
||||
else if (task.status === "succeeded") succeeded += 1;
|
||||
else if (task.status === "failed" || task.status === "cancelled") failed += 1;
|
||||
}
|
||||
return { today, active, succeeded, failed };
|
||||
return { total: tasks.length, today, active, succeeded, failed };
|
||||
}, [tasks]);
|
||||
const groupingActive = viewMode === "list" && groupEnabled;
|
||||
const visibleTaskGroups = useMemo(
|
||||
@@ -397,7 +397,6 @@ export default function TasksPage() {
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<TaskStatPills stats={taskStats} statusFilter={statusFilter} onFilterChange={setStatusFilter} />
|
||||
<ListToolbar
|
||||
className="library-toolbar task-library-toolbar"
|
||||
active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")}
|
||||
@@ -421,21 +420,11 @@ export default function TasksPage() {
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<TaskStatusFilterBar stats={taskStats} value={statusFilter} onChange={(value) => { setStatusFilter(value); setPage(1); }} />
|
||||
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
|
||||
<Select className="w-full sm:w-48" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
|
||||
<Select className="w-full sm:w-32" value={kindFilter} onChange={(value) => { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} />
|
||||
<Select className="w-full sm:w-44" value={modelFilter} onChange={(value) => { setModelFilter(value); setPage(1); }} options={[{ label: "全部模型", value: "all" }, ...modelOptions.map((model) => ({ label: model, value: model }))]} />
|
||||
<Segmented
|
||||
size="small"
|
||||
value={statusFilter}
|
||||
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
|
||||
options={[
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "失败/取消", value: "failed" },
|
||||
{ label: "运行中", value: "active" },
|
||||
{ label: "已完成", value: "succeeded" },
|
||||
]}
|
||||
/>
|
||||
</ListToolbar>
|
||||
</div>
|
||||
|
||||
@@ -492,7 +481,7 @@ export default function TasksPage() {
|
||||
<Drawer className="library-drawer" title="任务详情" open={Boolean(detailTask)} onClose={() => setDetailTask(null)} size="large" destroyOnHidden>
|
||||
{detailTask ? (
|
||||
<div className="space-y-5">
|
||||
<div className="grid border-y border-border text-sm sm:grid-cols-2">
|
||||
<div className="task-detail-facts grid text-sm sm:grid-cols-2">
|
||||
<InfoItem label="状态" value={statusLabel[detailTask.status]} />
|
||||
<InfoItem label="画布名称" value={getTaskCanvasContext(detailTask, canvasById, domainProjectNameById).canvasName} />
|
||||
<InfoItem label="任务类型" value={formatTaskKind(detailTask)} />
|
||||
@@ -503,7 +492,7 @@ export default function TasksPage() {
|
||||
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
|
||||
</div>
|
||||
{canQueryProviderTask(detailTask) ? <div className="flex justify-end"><Button icon={<RefreshCw className="size-4" />} loading={actingId === detailTask.id} onClick={() => void queryProviderTask(detailTask)}>手动查询任务</Button></div> : null}
|
||||
{detailTask.error ? <pre className="max-h-28 overflow-auto whitespace-pre-wrap border-l-2 border-red-500 bg-red-50 px-3 py-2 text-xs text-red-700 dark:bg-red-950/30 dark:text-red-300">{generationErrorMessage(detailTask.error)}</pre> : null}
|
||||
{detailTask.error ? <pre className="task-detail-error max-h-28 overflow-auto whitespace-pre-wrap px-3 py-2 text-xs">{generationErrorMessage(detailTask.error)}</pre> : null}
|
||||
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
|
||||
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
|
||||
<DetailBlock title="结果" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.resultJson)} />
|
||||
@@ -626,7 +615,7 @@ function formatDate(value?: string) {
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="min-w-0 border-b border-border px-0 py-2.5">
|
||||
<div className="task-detail-fact min-w-0 px-3 py-2.5">
|
||||
<Typography.Text type="secondary" className="block text-xs">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
export type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
|
||||
|
||||
export type TaskStats = { today: number; active: number; succeeded: number; failed: number };
|
||||
|
||||
export function TaskStatPills({ stats, statusFilter, onFilterChange }: { stats: TaskStats; statusFilter: TaskStatusFilter; onFilterChange: (value: TaskStatusFilter) => void }) {
|
||||
return (
|
||||
<div className="task-stat-pills">
|
||||
<span className="task-stat-pill">
|
||||
<span className="task-stat-dot" />
|
||||
今日生成<b>{stats.today}</b>
|
||||
</span>
|
||||
<button type="button" aria-pressed={statusFilter === "active"} className={`task-stat-pill is-clickable${statusFilter === "active" ? " is-active-filter" : ""}`} onClick={() => onFilterChange(statusFilter === "active" ? "all" : "active")}>
|
||||
<span className="task-stat-dot is-run" />
|
||||
运行中<b>{stats.active}</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={statusFilter === "succeeded"}
|
||||
className={`task-stat-pill is-clickable${statusFilter === "succeeded" ? " is-active-filter" : ""}`}
|
||||
onClick={() => onFilterChange(statusFilter === "succeeded" ? "all" : "succeeded")}
|
||||
>
|
||||
<span className="task-stat-dot is-ok" />
|
||||
已完成<b>{stats.succeeded}</b>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={statusFilter === "failed"}
|
||||
className={`task-stat-pill is-clickable is-fail${statusFilter === "failed" ? " is-active-filter" : ""}`}
|
||||
onClick={() => onFilterChange(statusFilter === "failed" ? "all" : "failed")}
|
||||
>
|
||||
<span className="task-stat-dot is-bad" />
|
||||
失败<b>{stats.failed}</b>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
web/src/pages/tasks/task-status-filter.tsx
Normal file
29
web/src/pages/tasks/task-status-filter.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Segmented } from "antd";
|
||||
|
||||
export type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
|
||||
|
||||
export type TaskStats = { total: number; today: number; active: number; succeeded: number; failed: number };
|
||||
|
||||
export function TaskStatusFilterBar({ stats, value, onChange }: { stats: TaskStats; value: TaskStatusFilter; onChange: (value: TaskStatusFilter) => void }) {
|
||||
const options = [
|
||||
{ value: "all", label: "全部", count: stats.total },
|
||||
{ value: "active", label: "运行中", count: stats.active },
|
||||
{ value: "succeeded", label: "已完成", count: stats.succeeded },
|
||||
{ value: "failed", label: "失败/取消", count: stats.failed },
|
||||
] satisfies Array<{ value: TaskStatusFilter; label: string; count: number }>;
|
||||
|
||||
return (
|
||||
<div className="task-status-filter">
|
||||
<span className="task-status-today">今日生成 <strong>{stats.today}</strong></span>
|
||||
<Segmented<TaskStatusFilter>
|
||||
size="small"
|
||||
value={value}
|
||||
options={options.map((option) => ({
|
||||
value: option.value,
|
||||
label: <span>{option.label}<b>{option.count}</b></span>,
|
||||
}))}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user