refactor(*): 用户工作台 - 统一画布、任务中心与设置页产品化交互

This commit is contained in:
ddCat
2026-08-14 23:44:55 +08:00
parent 4f28847543
commit d89a5a5b7b
29 changed files with 1662 additions and 2067 deletions

View File

@@ -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}

View 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;
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>
);
}

View 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>
);
}