feat(platform): 模型管理与后台能力 - 新增逻辑模型路由和响应拦截
This commit is contained in:
@@ -19,7 +19,7 @@ export function AuthSessionHydrator({ children }: { children: ReactNode }) {
|
||||
if (!cancelled) await applyUserSession(payload);
|
||||
})
|
||||
.catch(async () => {
|
||||
if (!cancelled) await applyUserSession({ user: null, systemChannels: [] });
|
||||
if (!cancelled) await applyUserSession({ user: null, logicalModels: [] });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Copy, Cpu, History, MessageSquareText, Plus, ScrollText, Settings2, Tra
|
||||
import { Button, Modal, Segmented, Select, Tooltip } from "antd";
|
||||
import { motion } from "motion/react";
|
||||
|
||||
import { modelDisplayName, modelOptionName, normalizeModelOptionValue, resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, modelIcon, normalizeModelOptionValue, resolveModelChannel, resolveModelRequestConfig, selectableModelsByCapability, useConfigStore, useEffectiveConfig, type AiConfig } from "@/stores/use-config-store";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { nanoid } from "nanoid";
|
||||
import { requestToolResponse, type ResponseFunctionTool, type ResponseInputMessage, type ResponseToolCall } from "@/services/api/image";
|
||||
@@ -22,6 +22,7 @@ import { cinematicAgentSessionOpsJson, createCinematicAgentSession, isAgentSessi
|
||||
import { summarizeCanvasContext } from "@/lib/canvas/canvas-context-summary";
|
||||
import { AgentChatComposer, AgentChatMessage, AgentPanelTabs, AgentWorkingMessage, type CanvasAgentChatMessage, type CanvasAgentMode } from "./canvas-agent-chat-ui";
|
||||
import { VoiceRecordingButton } from "@/components/conversation/voice-recording-button";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
import { AgentChatEmptyState, AgentPanelChrome } from "./canvas-agent-panel-chrome";
|
||||
import { CanvasLocalAgentPanel } from "./canvas-local-agent-panel";
|
||||
import { NODE_DEFAULT_SIZE } from "@/constant/canvas";
|
||||
@@ -1170,50 +1171,46 @@ function AgentTextModelPicker({ config, value, onChange }: { config: AiConfig; v
|
||||
value={current || undefined}
|
||||
className="agent-text-model-select w-full"
|
||||
popupMatchSelectWidth={288}
|
||||
options={options.map((model) => ({ value: model, label: `${modelDisplayName(config, model)} ${resolveModelChannel(config, model).name}` }))}
|
||||
options={options.map((model) => ({ value: model, label: agentModelLabel(config, model) }))}
|
||||
notFoundContent={<span className="block py-2 text-center text-xs text-foreground/48">暂无文本模型</span>}
|
||||
optionRender={(option) => {
|
||||
const model = String(option.value);
|
||||
return (
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<AgentModelIcon model={model} />
|
||||
<AgentModelIcon config={config} model={model} />
|
||||
<span className="min-w-0 flex-1 truncate">{modelDisplayName(config, model)}</span>
|
||||
<span className="shrink-0 text-xs opacity-55">{resolveModelChannel(config, model).name}</span>
|
||||
{agentModelSource(config, model) ? <span className="shrink-0 text-xs opacity-55">{agentModelSource(config, model)}</span> : null}
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
labelRender={() => (
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<AgentModelIcon model={current} />
|
||||
<AgentModelIcon config={config} model={current} />
|
||||
<span className="min-w-0 truncate">{current ? modelDisplayName(config, current) : "选择文本模型"}</span>
|
||||
{current ? <span className="shrink-0 opacity-55">{resolveModelChannel(config, current).name}</span> : null}
|
||||
{current && agentModelSource(config, current) ? <span className="shrink-0 opacity-55">{agentModelSource(config, current)}</span> : null}
|
||||
</span>
|
||||
)}
|
||||
onChange={onChange}
|
||||
aria-label="选择 Agent 文本模型"
|
||||
title={current ? `${modelDisplayName(config, current)} · ${resolveModelChannel(config, current).name}` : "选择文本模型"}
|
||||
title={current ? agentModelLabel(config, current) : "选择文本模型"}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentModelIcon({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(modelOptionName(model));
|
||||
return icon ? <img src={icon} alt="" className="size-4 shrink-0 dark:invert" /> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||
function agentModelSource(config: AiConfig, model: string) {
|
||||
const channel = resolveModelChannel(config, model);
|
||||
return channel.scope === "system" ? "" : channel.name;
|
||||
}
|
||||
|
||||
function resolveModelIcon(model: string) {
|
||||
// 与 model-picker 保持同一套厂商图标规则。
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
if (name.includes("gemini") || name.includes("google") || name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen") || name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) {
|
||||
return "/icons/gemini.svg";
|
||||
}
|
||||
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
|
||||
if (name.includes("grok")) return "/icons/grok.svg";
|
||||
if (name.includes("deepseek")) return "/icons/deepseek.svg";
|
||||
if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg";
|
||||
return "";
|
||||
function agentModelLabel(config: AiConfig, model: string) {
|
||||
const source = agentModelSource(config, model);
|
||||
return source ? `${modelDisplayName(config, model)} · ${source}` : modelDisplayName(config, model);
|
||||
}
|
||||
|
||||
function AgentModelIcon({ config, model }: { config: AiConfig; model: string }) {
|
||||
const icon = modelIcon(config, model);
|
||||
return icon ? <span className="inline-flex size-4 shrink-0 items-center justify-center"><ModelLogo icon={icon} size={16} /></span> : <Cpu className="size-4 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
function AssistantHistory({ sessions, activeSession, onOpen, onDelete }: { sessions: CanvasAssistantSession[]; activeSession: CanvasAssistantSession | null; onOpen: (id: string) => void; onDelete: (id: string) => void }) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeImageValue, normalizeVideoValue } from "@/lib/model-capabilities";
|
||||
import { modelCompatibilityError, resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { modelCompatibilityError, modelRequestOptions, resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -51,6 +51,7 @@ export function CanvasConfigNodePanel({ node, isRunning, inputSummary, onConfigC
|
||||
input: inputSummary,
|
||||
videoOperation: node.metadata?.videoEditOperation,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
|
||||
options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed }, mode),
|
||||
};
|
||||
const config = buildNodeConfig(globalConfig, node, mode, requirements);
|
||||
const videoProfile = mode === "video" ? modelCapabilityConfigFor(config, config.model).video! : undefined;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { resolveCanvasGenerationModel } from "@/lib/canvas/canvas-project-genera
|
||||
import { CreditSymbol, requestCreditCost } from "@/constant/credits";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { normalizeVideoDuration, normalizeVideoResolution } from "@/lib/video-generation-options";
|
||||
import { resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { modelRequestOptions, resolveCompatibleModel, defaultImageParamsForModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -78,6 +78,7 @@ export function CanvasNodePromptPanel({ node, isRunning, onPromptChange, onConfi
|
||||
},
|
||||
videoOperation: node.metadata?.videoEditOperation,
|
||||
videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds,
|
||||
options: modelRequestOptions({ ...globalConfig, size: node.metadata?.size || globalConfig.size, quality: node.metadata?.quality || globalConfig.quality, count: String(node.metadata?.count || globalConfig.count), videoSeconds: node.metadata?.seconds || globalConfig.videoSeconds, vquality: node.metadata?.vquality || globalConfig.vquality, videoGenerateAudio: node.metadata?.generateAudio || globalConfig.videoGenerateAudio, videoWatermark: node.metadata?.watermark || globalConfig.videoWatermark, audioVoice: node.metadata?.audioVoice || globalConfig.audioVoice, audioFormat: node.metadata?.audioFormat || globalConfig.audioFormat, audioSpeed: node.metadata?.audioSpeed || globalConfig.audioSpeed }, mode),
|
||||
};
|
||||
const config = buildNodeConfig(globalConfig, node, mode, requirements);
|
||||
const generationCount = Math.max(1, Math.min(15, Math.floor(Math.abs(Number(config.count)) || 1)));
|
||||
|
||||
@@ -8,7 +8,7 @@ import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { buildTimelineImportSegments, type CanvasTimelineSegmentItem } from "@/lib/canvas/canvas-video-timeline-segments";
|
||||
import { listVideoReferenceModels } from "@/lib/canvas/canvas-video-regeneration";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
import { resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { modelRequestOptions, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { navigateToSettings } from "@/lib/settings-navigation";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { resolveMediaUrl } from "@/services/file-storage";
|
||||
@@ -117,6 +117,7 @@ export function CanvasVideoSegmentDialog({ node, nodes, connections, open, mode,
|
||||
input: { textCount: hasPrompt ? 1 : 0, imageCount: 0, videoCount: 1, audioCount: 0, characterCount: 0 },
|
||||
videoOperation: operation,
|
||||
videoSeconds: config.videoSeconds,
|
||||
options: modelRequestOptions(config, "video"),
|
||||
}),
|
||||
[config.videoSeconds, hasPrompt, operation],
|
||||
);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useUserStore } from "@/stores/use-user-store";
|
||||
|
||||
type OSSFormValues = {
|
||||
enabled?: boolean;
|
||||
provider: "aliyun" | "tencent";
|
||||
provider: "aliyun" | "tencent" | "qiniu";
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
cdnBaseUrl?: string;
|
||||
@@ -27,9 +27,10 @@ export function UserOSSSettingsForm() {
|
||||
const savedAt = formatSavedAt(setting?.updatedAt);
|
||||
const provider = Form.useWatch("provider", form) || "aliyun";
|
||||
const isTencentCOS = provider === "tencent";
|
||||
const isQiniuKodo = provider === "qiniu";
|
||||
const hasCurrentProviderSecret = Boolean(setting && setting.provider === provider && setting.hasAccessKeySecret);
|
||||
const accessKeyIdLabel = isTencentCOS ? "SecretId" : "AccessKey ID";
|
||||
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : "AccessKey Secret";
|
||||
const accessKeyIdLabel = isTencentCOS ? "SecretId" : isQiniuKodo ? "AccessKey" : "AccessKey ID";
|
||||
const accessKeySecretLabel = isTencentCOS ? "SecretKey" : isQiniuKodo ? "SecretKey" : "AccessKey Secret";
|
||||
|
||||
useEffect(() => {
|
||||
if (!actor?.id) return;
|
||||
@@ -85,7 +86,7 @@ export function UserOSSSettingsForm() {
|
||||
<Cloud className="size-4" />
|
||||
我的对象存储
|
||||
</div>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">支持阿里云 OSS 与腾讯云 COS。启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。历史资源继续使用创建时的存储与密钥配置,同一存储位置的 CDN 域名会跟随当前配置。</p>
|
||||
<p className="mt-1 max-w-3xl text-xs leading-5 text-foreground/55">启用后,新上传和新生成的媒体优先写入你的存储桶;停用时回退到平台存储。</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-2">
|
||||
<Tag color={setting?.enabled ? "success" : "default"}>{setting?.enabled ? "已启用" : "未启用"}</Tag>
|
||||
@@ -101,21 +102,21 @@ export function UserOSSSettingsForm() {
|
||||
</Form.Item>
|
||||
<Form.Item name="provider" label="存储服务" rules={[{ required: true, message: "请选择存储服务" }]} className="mb-3">
|
||||
<Select
|
||||
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }]}
|
||||
options={[{ label: "阿里云 OSS", value: "aliyun" }, { label: "腾讯云 COS", value: "tencent" }, { label: "七牛云 Kodo", value: "qiniu" }]}
|
||||
onChange={(nextProvider: OSSFormValues["provider"]) => {
|
||||
if (nextProvider !== provider) form.setFieldsValue({ region: "", endpoint: "", cdnBaseUrl: "", bucket: "", accessKeyId: "", accessKeySecret: "" });
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="region" label="Region" className="mb-3">
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "ap-guangzhou" : "oss-cn-hangzhou"} />
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "ap-guangzhou" : isQiniuKodo ? "z0 / cn-east-1" : "oss-cn-hangzhou"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endpoint" label="Endpoint" extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
|
||||
<Form.Item name="endpoint" label={isQiniuKodo ? "上传 Endpoint" : "Endpoint"} extra={isTencentCOS ? "可留空,系统会根据 Region 生成标准 COS Endpoint。" : undefined} className="mb-3">
|
||||
<Input inputMode="url" spellCheck={false} placeholder={isTencentCOS ? "https://cos.ap-guangzhou.myqcloud.com" : isQiniuKodo ? "https://up-z0.qiniup.com" : "https://oss-cn-hangzhou.aliyuncs.com"} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="cdnBaseUrl"
|
||||
label="CDN 加速域名"
|
||||
label={isQiniuKodo ? "绑定域名" : "CDN 加速域名"}
|
||||
extra={isTencentCOS
|
||||
? "选填。上传仍走 Endpoint,下载与预览改走 CDN;私有桶需开启 CDN 私有存储桶访问。CDN URL 不附带 COS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"
|
||||
: "选填。上传仍走 Endpoint,下载与预览改走 CDN;阿里云私有 Bucket 需开启 CDN 私有 Bucket 回源。CDN URL 不附带 OSS 签名,未配置 CDN URL 鉴权时链接将长期可访问。"}
|
||||
@@ -125,16 +126,16 @@ export function UserOSSSettingsForm() {
|
||||
<Input inputMode="url" spellCheck={false} placeholder="https://media.example.com" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bucket" label="Bucket" className="mb-3">
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : "my-canvas-assets"} />
|
||||
<Input spellCheck={false} placeholder={isTencentCOS ? "my-canvas-assets-1250000000" : isQiniuKodo ? "七牛云存储空间名称" : "my-canvas-assets"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="pathPrefix" label="路径前缀" className="mb-3">
|
||||
<Input spellCheck={false} placeholder="infinite-canvas" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessKeyId" label={accessKeyIdLabel} className="mb-3 xl:col-span-1">
|
||||
<Input autoComplete="off" spellCheck={false} placeholder={isTencentCOS ? "腾讯云 SecretId" : "阿里云 AccessKey ID"} />
|
||||
<Input autoComplete="off" spellCheck={false} placeholder={isTencentCOS ? "腾讯云 SecretId" : isQiniuKodo ? "七牛云 AccessKey" : "阿里云 AccessKey ID"} />
|
||||
</Form.Item>
|
||||
<Form.Item name="accessKeySecret" label={hasCurrentProviderSecret ? `${accessKeySecretLabel}(留空保留)` : accessKeySecretLabel} className="mb-3 xl:col-span-2">
|
||||
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={hasCurrentProviderSecret ? "留空保留已加密密钥" : isTencentCOS ? "腾讯云 SecretKey" : "阿里云 AccessKey Secret"} />
|
||||
<Input.Password autoComplete="new-password" spellCheck={false} placeholder={hasCurrentProviderSecret ? "留空保留已加密密钥" : isTencentCOS ? "腾讯云 SecretKey" : isQiniuKodo ? "七牛云 SecretKey" : "阿里云 AccessKey Secret"} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Button, Pagination } from "antd";
|
||||
import { RotateCcw } from "lucide-react";
|
||||
import { ListFilter, RotateCcw } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function WorkspacePage({ children, className, grid = false, fluid = false }: { children: ReactNode; className?: string; grid?: boolean; fluid?: boolean }) {
|
||||
export function WorkspacePage({ children, className, grid = false, fluid = false, scroll = true }: { children: ReactNode; className?: string; grid?: boolean; fluid?: boolean; scroll?: boolean }) {
|
||||
return (
|
||||
<main className={cn("app-user-content app-workspace-scroll h-full overflow-y-auto text-foreground", grid && "app-workspace-grid", className)}>
|
||||
<main className={cn("app-user-content h-full text-foreground", scroll && "app-workspace-scroll overflow-y-auto", grid && "app-workspace-grid", className)}>
|
||||
<div className={fluid ? "h-full w-full" : "w-full px-3 py-3 sm:px-4 sm:py-4 xl:px-5"}>{children}</div>
|
||||
</main>
|
||||
);
|
||||
@@ -29,11 +30,28 @@ export function PageHeader({ title, description, meta, actions }: { title: strin
|
||||
);
|
||||
}
|
||||
|
||||
export function ListToolbar({ children, trailing, active, onReset, className }: { children: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void; className?: string }) {
|
||||
export function ListToolbar({ children, filters, activeFilters, trailing, active, onReset, className }: { children: ReactNode; filters?: ReactNode; activeFilters?: ReactNode; trailing?: ReactNode; active?: boolean; onReset?: () => void; className?: string }) {
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (active) setFiltersOpen(true);
|
||||
}, [active]);
|
||||
|
||||
return (
|
||||
<div className={cn("mt-3 flex min-h-12 flex-col gap-2 pb-3 lg:flex-row lg:items-center lg:justify-between", className)}>
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2.5">{children}</div>
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<div className={cn("admin-list-toolbar mt-3 flex min-h-12 flex-col gap-2 pb-3 lg:flex-row lg:items-center lg:justify-between", className)}>
|
||||
<div className="admin-list-toolbar-main flex min-w-0 flex-1 flex-wrap items-center gap-2.5">
|
||||
{children}
|
||||
{filters ? (
|
||||
<>
|
||||
<Button type="default" className="admin-filter-toggle" aria-expanded={filtersOpen} icon={<ListFilter className="size-3.5" />} onClick={() => setFiltersOpen((open) => !open)}>
|
||||
筛选{active ? <span className="admin-filter-active-dot" aria-label="有已应用筛选" /> : null}
|
||||
</Button>
|
||||
<div className={cn("admin-list-toolbar-filters flex flex-wrap items-center gap-2", filtersOpen && "is-open")}>{filters}</div>
|
||||
</>
|
||||
) : null}
|
||||
{activeFilters ? <div className="admin-list-toolbar-chips">{activeFilters}</div> : null}
|
||||
</div>
|
||||
<div className="admin-list-toolbar-actions flex shrink-0 flex-wrap items-center gap-2">
|
||||
{active && onReset ? <Button type="text" icon={<RotateCcw className="size-3.5" />} onClick={onReset}>重置</Button> : null}
|
||||
{trailing}
|
||||
</div>
|
||||
@@ -49,19 +67,22 @@ export function CollectionGrid({ children, className }: { children: ReactNode; c
|
||||
return <div className={cn("mt-4 grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fill,minmax(248px,1fr))]", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function PaginationBar({ current, pageSize, total, onChange, pageSizeOptions = [20, 50, 100] }: { current: number; pageSize: number; total: number; onChange: (page: number, pageSize: number) => void; pageSizeOptions?: number[] }) {
|
||||
if (total <= pageSize && current === 1) return null;
|
||||
export function PaginationBar({ current, pageSize, total, onChange, pageSizeOptions = [20, 50, 100], alwaysShow = false }: { current: number; pageSize: number; total: number; onChange: (page: number, pageSize: number) => void; pageSizeOptions?: number[]; alwaysShow?: boolean }) {
|
||||
if (!alwaysShow && total <= pageSize && current === 1) return null;
|
||||
const start = total === 0 ? 0 : (current - 1) * pageSize + 1;
|
||||
const end = total === 0 ? 0 : Math.min(current * pageSize, total);
|
||||
return (
|
||||
<div className="app-pagination-bar mt-4 flex min-h-10 min-w-0 items-center justify-end px-2 py-1.5">
|
||||
<div className="app-pagination-bar admin-pagination-bar min-w-0">
|
||||
<span className="admin-pagination-total">{total === 0 ? "共 0 条" : `${start}-${end} / 共 ${total} 条`}</span>
|
||||
<Pagination
|
||||
size="small"
|
||||
current={current}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
showSizeChanger
|
||||
showSizeChanger={pageSizeOptions.length > 1}
|
||||
responsive
|
||||
showLessItems
|
||||
pageSizeOptions={pageSizeOptions.map(String)}
|
||||
showTotal={(value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export function WorkspaceSidebarFooter({ expandedClassName, collapsedClassName,
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await logout();
|
||||
await applyUserSession({ user: null, systemChannels: [] });
|
||||
await applyUserSession({ user: null, logicalModels: [] });
|
||||
setMenuOpen(false);
|
||||
message.success("已退出登录");
|
||||
navigate("/login", { replace: true });
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Input, InputNumber, Segmented, Select, Switch } from "antd";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { defaultImageCapabilityConfig, defaultModelCapabilityConfig, type ImageCapabilityConfig, type ModelCapabilityConfig, type TextCapabilityConfig, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import type { ModelProtocol } from "@/lib/model-protocols";
|
||||
import { VIDEO_RESOLUTION_CAPABILITY_OPTIONS } from "@/lib/video-generation-options";
|
||||
|
||||
@@ -21,12 +21,15 @@ type Props = {
|
||||
value?: ModelCapabilityConfig;
|
||||
onChange?: (value: ModelCapabilityConfig) => void;
|
||||
protocol?: ModelProtocol;
|
||||
capability?: "image" | "video";
|
||||
capability?: "text" | "image" | "video";
|
||||
model?: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export function ModelCapabilityEditor({ value, onChange, protocol, capability = "video", model = "", disabled = false }: Props) {
|
||||
if (capability === "text") {
|
||||
return <TextCapabilityEditor value={value} onChange={onChange} protocol={protocol} disabled={disabled} />;
|
||||
}
|
||||
if (capability === "image") {
|
||||
return <ImageCapabilityEditor value={value} onChange={onChange} protocol={protocol} model={model} disabled={disabled} />;
|
||||
}
|
||||
@@ -38,7 +41,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
const resolutionOptions = Array.from(new Set([...VIDEO_RESOLUTION_CAPABILITY_OPTIONS, ...profile.resolutions]));
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
|
||||
<div className="admin-capability-editor space-y-3 rounded-md bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><div className="text-sm font-medium">视频能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">这些参数会同步到创造页、画布和生成校验</div></div>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/40">协议模板可继续调整</span>
|
||||
@@ -77,7 +80,7 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="画面比例"><Select mode="multiple" className="w-full" disabled={disabled} value={profile.ratios} options={ratioOptions.map((item) => ({ label: item, value: item }))} onChange={(ratios) => update({ ratios, defaultRatio: ratios.includes(profile.defaultRatio) ? profile.defaultRatio : ratios[0] || "16:9" })} /></Field>
|
||||
<Field label="默认比例"><Select className="w-full" disabled={disabled} value={profile.defaultRatio} options={profile.ratios.map((item) => ({ label: item, value: item }))} onChange={(defaultRatio) => update({ defaultRatio })} /></Field>
|
||||
<Field label="输出分辨率"><Select mode="tags" className="w-full" disabled={disabled} value={profile.resolutions} tokenSeparators={[","]} placeholder="选择标准档位或输入 768p 等模型专属值" options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "" })} /></Field>
|
||||
<Field label="输出分辨率"><Select mode="tags" className="admin-capability-tags w-full" disabled={disabled} value={profile.resolutions} tokenSeparators={[","]} placeholder="选择标准档位或输入 768p 等模型专属值" options={resolutionOptions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(resolutions) => update({ resolutions, defaultResolution: resolutions.includes(profile.defaultResolution) ? profile.defaultResolution : resolutions[0] || "" })} /></Field>
|
||||
<Field label="默认分辨率"><Select className="w-full" disabled={disabled} value={profile.defaultResolution} options={profile.resolutions.map((item) => ({ label: item.toUpperCase(), value: item }))} onChange={(defaultResolution) => update({ defaultResolution })} /></Field>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
@@ -96,6 +99,31 @@ export function ModelCapabilityEditor({ value, onChange, protocol, capability =
|
||||
);
|
||||
}
|
||||
|
||||
function TextCapabilityEditor({ value, onChange, protocol, disabled }: Pick<Props, "value" | "onChange" | "protocol" | "disabled">) {
|
||||
const profile = value?.text || defaultModelCapabilityConfig(protocol).text!;
|
||||
const updateReferences = (patch: Partial<TextCapabilityConfig["references"]>) => {
|
||||
onChange?.({ version: 1, text: { references: { ...profile.references, ...patch } } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-capability-editor space-y-3 rounded-md bg-muted/20 p-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">文本理解能力</div>
|
||||
<div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">默认不假设支持图片或视频,只有明确配置后相关请求才会进入该模型。</div>
|
||||
</div>
|
||||
<CapabilityGroup title="输入限制">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||
<NumberField label="提示词最大字符数" value={profile.references.promptMaxChars} min={1} max={1_000_000} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ promptMaxChars: next || 1 })} />
|
||||
<NumberField label="最大参考图片数" value={profile.references.maxImages} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImages: next || 0 })} />
|
||||
<NumberField label="单张图片上限 MB" value={bytesToMB(profile.references.maxImageBytes)} min={0} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxImageBytes: mbToBytes(next) })} />
|
||||
<NumberField label="最大参考视频数" value={profile.references.maxVideos} min={0} max={100} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxVideos: next || 0 })} />
|
||||
<NumberField label="单个视频上限 MB" value={bytesToMB(profile.references.maxVideoBytes)} min={0} disabled={Boolean(disabled)} onChange={(next) => updateReferences({ maxVideoBytes: mbToBytes(next) })} />
|
||||
</div>
|
||||
</CapabilityGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: Required<Pick<Props, "model" | "disabled">> & Pick<Props, "value" | "onChange" | "protocol">) {
|
||||
const profile = value?.image || defaultImageCapabilityConfig(protocol, model);
|
||||
const update = (patch: Partial<ImageCapabilityConfig>) => onChange?.({ version: 1, image: { ...profile, ...patch } });
|
||||
@@ -104,7 +132,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: R
|
||||
const updateQuality = (patch: Partial<ImageCapabilityConfig["quality"]>) => update({ quality: { ...profile.quality, ...patch } });
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border border-border/70 bg-muted/10 p-3">
|
||||
<div className="admin-capability-editor space-y-3 rounded-md bg-muted/10 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div><div className="text-sm font-medium">图片能力参数</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/48">生成界面和后端请求都会按此处裁剪参数</div></div>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/40">当前模型独立生效</span>
|
||||
@@ -132,7 +160,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: R
|
||||
}}
|
||||
/>
|
||||
{profile.size.parameter !== "none" ? <>
|
||||
<Field label="支持值"><Select mode="tags" className="w-full" disabled={disabled} value={profile.size.values} tokenSeparators={[","]} placeholder="例如 1:1、1024x1024" onChange={(values) => updateSize({ values, default: values.includes(profile.size.default) || profile.size.allowCustom ? profile.size.default : values[0] || "auto" })} /></Field>
|
||||
<Field label="支持值"><Select mode="tags" className="admin-capability-tags w-full" disabled={disabled} value={profile.size.values} tokenSeparators={[","]} placeholder="例如 1:1、1024x1024" onChange={(values) => updateSize({ values, default: values.includes(profile.size.default) || profile.size.allowCustom ? profile.size.default : values[0] || "auto" })} /></Field>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="默认值"><Select className="w-full" disabled={disabled} value={profile.size.default} options={profile.size.values.map((item) => ({ label: item, value: item }))} onChange={(defaultValue) => updateSize({ default: defaultValue })} /></Field>
|
||||
<ParameterField label="允许自定义" description="允许用户输入支持值之外的尺寸" supported={profile.size.allowCustom} disabled={disabled} onChange={(allowCustom) => updateSize({ allowCustom })} />
|
||||
@@ -143,7 +171,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: R
|
||||
<CapabilityGroup title="可选生成参数">
|
||||
<ParameterField label="图片质量" description="发送 quality 参数" supported={profile.quality.supported} disabled={disabled} onChange={(supported) => updateQuality({ supported })} />
|
||||
{profile.quality.supported ? <div className="grid gap-2 sm:grid-cols-2">
|
||||
<Field label="质量支持值"><Select mode="tags" className="w-full" disabled={disabled} value={profile.quality.values} tokenSeparators={[","]} onChange={(values) => updateQuality({ values, default: values.includes(profile.quality.default) ? profile.quality.default : values[0] || "auto" })} /></Field>
|
||||
<Field label="质量支持值"><Select mode="tags" className="admin-capability-tags w-full" disabled={disabled} value={profile.quality.values} tokenSeparators={[","]} onChange={(values) => updateQuality({ values, default: values.includes(profile.quality.default) ? profile.quality.default : values[0] || "auto" })} /></Field>
|
||||
<Field label="默认质量"><Select className="w-full" disabled={disabled} value={profile.quality.default} options={profile.quality.values.map((item) => ({ label: item, value: item }))} onChange={(defaultValue) => updateQuality({ default: defaultValue })} /></Field>
|
||||
</div> : null}
|
||||
<BooleanField label="透明背景" value={profile.transparentBackground} disabled={disabled} onChange={(transparentBackground) => update({ transparentBackground })} />
|
||||
@@ -157,7 +185,7 @@ function ImageCapabilityEditor({ value, onChange, protocol, model, disabled }: R
|
||||
}
|
||||
|
||||
function CapabilityGroup({ title, children }: { title: string; children: ReactNode }) {
|
||||
return <section className="space-y-2"><div className="text-xs font-semibold text-foreground/65">{title}</div>{children}</section>;
|
||||
return <details open className="admin-capability-group rounded-lg bg-muted/10 p-3"><summary className="cursor-pointer list-none text-xs font-semibold text-foreground/70">{title}</summary><div className="mt-3 space-y-2">{children}</div></details>;
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
@@ -165,7 +193,7 @@ function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
}
|
||||
|
||||
function NumberField({ label, value, min, max, disabled, onChange }: { label: string; value?: number; min: number; max?: number; disabled: boolean; onChange: (value: number | null) => void }) {
|
||||
return <Field label={label}><InputNumber className="w-full" disabled={disabled} min={min} max={max} precision={0} value={value} onChange={onChange} /></Field>;
|
||||
return <div className="flex items-center justify-between gap-3"><span className="min-w-0 text-xs text-foreground/62">{label}</span><InputNumber className="w-32 shrink-0" disabled={disabled} min={min} max={max} precision={0} value={value} onChange={onChange} /></div>;
|
||||
}
|
||||
|
||||
function BooleanField({ label, value, disabled, onChange }: { label: string; value: { supported: boolean; default: boolean }; disabled: boolean; onChange: (value: { supported: boolean; default: boolean }) => void }) {
|
||||
|
||||
74
web/src/components/model-logo.tsx
Normal file
74
web/src/components/model-logo.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { useMemo, useState, type ComponentType, type SVGProps } from "react";
|
||||
import { Input, Popover } from "antd";
|
||||
import { Cpu, Search, X } from "lucide-react";
|
||||
|
||||
import * as LobeIcons from "@lobehub/icons/es/icons";
|
||||
import { toc } from "@lobehub/icons/es/toc";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type LobeIconComponent = ComponentType<SVGProps<SVGSVGElement> & { size?: number | string }>;
|
||||
|
||||
const iconRegistry = LobeIcons as unknown as Record<string, LobeIconComponent>;
|
||||
const iconOptions = toc
|
||||
.filter((item) => item.group === "model" || item.group === "provider")
|
||||
.map((item) => ({ id: item.id, title: item.fullTitle || item.title }))
|
||||
.filter((item) => Boolean(iconRegistry[item.id]));
|
||||
|
||||
export function ModelLogo({ icon, size = 18, className }: { icon?: string; size?: number; className?: string }) {
|
||||
const Icon = icon ? iconRegistry[icon] : undefined;
|
||||
if (!Icon) return <Cpu className={cn("shrink-0 text-foreground/45", className)} size={size} aria-hidden />;
|
||||
return <Icon size={size} className={cn("shrink-0", className)} aria-hidden />;
|
||||
}
|
||||
|
||||
export function ModelIconPicker({ value, onChange }: { value?: string; onChange?: (value: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const filteredIcons = useMemo(() => {
|
||||
const query = keyword.trim().toLowerCase();
|
||||
return query ? iconOptions.filter((item) => `${item.id} ${item.title}`.toLowerCase().includes(query)) : iconOptions;
|
||||
}, [keyword]);
|
||||
|
||||
const content = (
|
||||
<div className="w-full max-w-xl space-y-2" data-canvas-no-zoom>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input size="small" prefix={<Search className="size-3.5 text-foreground/40" />} value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索 Logo" allowClear />
|
||||
{value ? (
|
||||
<button type="button" className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-foreground/45 hover:text-foreground" onClick={() => onChange?.("")} aria-label="清除 Logo">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="grid max-h-96 grid-cols-12 gap-1 overflow-y-auto pr-1" role="listbox" aria-label="模型 Logo">
|
||||
{filteredIcons.map((item) => {
|
||||
const selected = value === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={selected}
|
||||
title={item.title}
|
||||
className={cn("flex size-10 items-center justify-center rounded-md text-foreground/75 hover:bg-muted/40", selected && "bg-muted/60 text-foreground")}
|
||||
onClick={() => {
|
||||
onChange?.(item.id);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ModelLogo icon={item.id} size={20} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover trigger="click" open={open} onOpenChange={setOpen} arrow={{ pointAtCenter: true }} placement="bottomLeft" classNames={{ root: "model-logo-picker-popover" }} content={content}>
|
||||
<button type="button" className="flex min-h-9 w-full items-center gap-2 rounded-md border border-border/60 bg-background px-2.5 text-left text-sm hover:bg-muted/20" aria-label="选择模型 Logo">
|
||||
<ModelLogo icon={value} size={20} />
|
||||
<span className="min-w-0 flex-1 truncate text-foreground/70">{value ? iconOptions.find((item) => item.id === value)?.title || value : "选择 Logo"}</span>
|
||||
</button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||||
import { Check, ChevronDown, Coins, Cpu } from "lucide-react";
|
||||
import { Check, ChevronDown, Coins } from "lucide-react";
|
||||
import { Popover } from "antd";
|
||||
|
||||
import { canvasThemes, type CanvasTheme } from "@/lib/canvas-theme";
|
||||
import { modelCapabilityConfigFor, videoDurationOptions } from "@/lib/model-capabilities";
|
||||
import { compatibleModelInGroup, configuredModelDisplayName, groupModelsByDisplayName, modelCompatibilityError, resolveCompatibleModel, type ModelRequirements } from "@/lib/model-selection";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { modelDisplayName, modelOptionLabel, modelOptionName, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { modelDisplayName, modelIcon, modelOptionName, PUBLIC_MODEL_CATALOG_ID, resolveModelChannel, selectableModelsByCapability, type AiConfig, type ModelCapability } from "@/stores/use-config-store";
|
||||
import { useThemeStore } from "@/stores/use-theme-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
import { ModelLogo } from "@/components/model-logo";
|
||||
|
||||
type ModelPickerProps = {
|
||||
config: AiConfig;
|
||||
@@ -45,7 +46,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
.map((channel) => ({
|
||||
key: channel.id,
|
||||
label: channel.name || "未命名渠道",
|
||||
scope: channel.scope === "system" ? "系统渠道" : "自定义渠道",
|
||||
scope: channel.id === PUBLIC_MODEL_CATALOG_ID ? "" : channel.scope === "system" ? "平台服务" : "我的模型",
|
||||
models: groupModelsByDisplayName(
|
||||
config,
|
||||
options.filter((model) => resolveModelChannel(config, model).id === channel.id),
|
||||
@@ -138,9 +139,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
<section key={group.key} className="canvas-model-picker-group min-w-0 overflow-hidden">
|
||||
<div className="canvas-model-picker-group-label" style={{ color: theme.node.muted }}>
|
||||
<span className="truncate">{group.label}</span>
|
||||
<span className="shrink-0" style={{ color: theme.node.muted }}>
|
||||
{group.scope}
|
||||
</span>
|
||||
{group.scope ? <span className="shrink-0" style={{ color: theme.node.muted }}>{group.scope}</span> : null}
|
||||
</div>
|
||||
<div className="grid min-w-0 gap-1">
|
||||
{group.models.map((modelGroup) => {
|
||||
@@ -207,7 +206,7 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
>
|
||||
<span className="canvas-model-picker-label flex min-w-0 items-center gap-1.5">
|
||||
<span className="canvas-model-picker-trigger-icon" style={{ background: theme.toolbar.itemHover }}>
|
||||
<ModelIcon model={current} />
|
||||
<ModelIcon config={config} model={current} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{current ? (creationVariant ? pickerModelDisplayName(config, current, showConfiguredModelName) : pickerModelOptionLabel(config, current, showConfiguredModelName)) : placeholder}</span>
|
||||
{showSelectedPrice && creditsEnabled ? <ModelPrice price={currentPrice} compact /> : null}
|
||||
@@ -221,8 +220,8 @@ export function ModelPicker({ config, value, onChange, capability, className, fu
|
||||
|
||||
function emptyModelLabel(config: AiConfig, capability?: ModelCapability) {
|
||||
const label = capability === "image" ? "生图" : capability === "video" ? "视频" : capability === "text" ? "文本" : capability === "audio" ? "音频" : "";
|
||||
if (capability && config.models.length) return `当前渠道没有匹配的${label}模型`;
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "请先到配置里添加渠道和模型";
|
||||
if (capability && config.models.length) return `暂无支持当前输入的${label}模型`;
|
||||
return config.models.length ? `暂无匹配的${label}模型` : "当前没有可用模型,请联系管理员或检查模型配置";
|
||||
}
|
||||
|
||||
function ModelLabel({
|
||||
@@ -245,12 +244,15 @@ function ModelLabel({
|
||||
disabledReason?: string;
|
||||
}) {
|
||||
const meta = modelMenuMeta(model, capability);
|
||||
const channel = resolveModelChannel(config, model);
|
||||
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
|
||||
const logicalSpec = logicalCost?.logicalCapabilitySpec;
|
||||
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
|
||||
const capabilitySummary = disabledReason || (videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
|
||||
const capabilitySummary = disabledReason || logicalCost?.description?.trim() || (logicalSpec ? logicalCapabilitySummary(logicalSpec) : videoProfile ? `${formatDurationSummary(videoProfile)} · ${videoProfile.resolutions.map((item) => item.toUpperCase()).join("/")}` : meta.description);
|
||||
return (
|
||||
<span className="flex w-full min-w-0 items-center gap-1.5 overflow-hidden py-0">
|
||||
<span className="grid size-6 shrink-0 place-items-center rounded-md" style={{ background: theme.toolbar.itemHover }}>
|
||||
<ModelIcon model={model} />
|
||||
<ModelIcon config={config} model={model} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 overflow-hidden">
|
||||
<span className="block min-w-0 truncate text-[var(--fs-label)] font-medium leading-none">{pickerModelDisplayName(config, model, showConfiguredModelName)}</span>
|
||||
@@ -268,6 +270,59 @@ function ModelLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function logicalCapabilitySummary(spec: NonNullable<NonNullable<AiConfig["channels"][number]["modelCosts"]>[number]["logicalCapabilitySpec"]>) {
|
||||
const operationLabels: Record<string, string> = {
|
||||
text_to_video: "文生视频",
|
||||
image_to_video: "图生视频",
|
||||
audio_to_video: "音频生视频",
|
||||
extend: "视频续写",
|
||||
inpaint: "局部修改",
|
||||
replace_element: "元素替换",
|
||||
camera_motion: "运镜调整",
|
||||
style_transfer: "风格迁移",
|
||||
};
|
||||
const inputLabels: Record<string, { label: string; unit: string }> = {
|
||||
image: { label: spec.capability === "text" ? "图片理解" : "参考图片", unit: "张" },
|
||||
video: { label: spec.capability === "text" ? "视频理解" : "参考视频", unit: "个" },
|
||||
audio: { label: "参考音频", unit: "个" },
|
||||
mask: { label: "蒙版", unit: "张" },
|
||||
};
|
||||
const optionLabels: Record<string, string> = {
|
||||
size: "画面比例",
|
||||
aspectRatio: "画面比例",
|
||||
quality: "生成质量",
|
||||
count: "输出数量",
|
||||
videoSeconds: "视频时长",
|
||||
duration: "视频时长",
|
||||
vquality: "输出分辨率",
|
||||
resolution: "输出分辨率",
|
||||
audioVoice: "音色",
|
||||
audioFormat: "音频格式",
|
||||
audioSpeed: "语速",
|
||||
};
|
||||
const values: string[] = [];
|
||||
values.push(...(spec.operations || []).map((operation) => operationLabels[operation] || operation));
|
||||
for (const [name, constraint] of Object.entries(spec.inputs || {})) {
|
||||
if (constraint.max <= 0) continue;
|
||||
const definition = inputLabels[name];
|
||||
if (!definition) continue;
|
||||
values.push(spec.capability === "text" ? `支持${definition.label}` : `${definition.label}最多 ${constraint.max}${definition.unit}`);
|
||||
}
|
||||
for (const [name, constraint] of Object.entries(spec.options || {})) {
|
||||
const label = optionLabels[name];
|
||||
if (!label) continue;
|
||||
if (constraint.values?.length) values.push(`${label} ${constraint.values.map(publicScalarLabel).join("/")}`);
|
||||
else if (constraint.min !== undefined && constraint.max !== undefined) values.push(`${label} ${constraint.min}-${constraint.max}`);
|
||||
}
|
||||
return values.slice(0, 2).join(" · ") || "智能匹配当前输入";
|
||||
}
|
||||
|
||||
function publicScalarLabel(value: unknown) {
|
||||
if (value === true) return "支持";
|
||||
if (value === false) return "关闭";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function formatDurationSummary(profile: NonNullable<ReturnType<typeof modelCapabilityConfigFor>["video"]>) {
|
||||
const values = videoDurationOptions(profile);
|
||||
if (profile.duration.selection === "enum") return values.map((item) => `${item}s`).join("/");
|
||||
@@ -292,7 +347,9 @@ function pickerModelDisplayName(config: AiConfig, model: string, showConfiguredM
|
||||
}
|
||||
|
||||
function pickerModelOptionLabel(config: AiConfig, model: string, showConfiguredModelName: boolean) {
|
||||
return showConfiguredModelName ? `${configuredModelDisplayName(config, model)}(${resolveModelChannel(config, model).name})` : modelOptionLabel(config, model);
|
||||
const displayName = showConfiguredModelName ? configuredModelDisplayName(config, model) : modelDisplayName(config, model);
|
||||
const channel = resolveModelChannel(config, model);
|
||||
return channel.scope === "system" ? displayName : `${displayName}(${channel.name})`;
|
||||
}
|
||||
|
||||
function ModelPrice({ price, compact = false }: { price: ModelMenuPrice | null | undefined; compact?: boolean }) {
|
||||
@@ -324,25 +381,9 @@ function modelMenuMeta(model: string, capability?: ModelCapability): { descripti
|
||||
if (name.includes("claude")) return { description: "长文本、推理与创意写作", time: "10s" };
|
||||
if (name.includes("gemini")) return { description: "多模态理解与快速文本生成", time: "10s" };
|
||||
if (name.includes("deepseek")) return { description: "推理、代码和结构化文本", time: "10s" };
|
||||
return { description: capability === "text" ? "文本生成模型" : "当前渠道模型", time: "10s" };
|
||||
return { description: capability === "text" ? "文本生成模型" : "当前模型", time: "10s" };
|
||||
}
|
||||
|
||||
export function ModelIcon({ model }: { model: string }) {
|
||||
const icon = resolveModelIcon(modelOptionName(model));
|
||||
const monochrome = icon === "/icons/openai.svg" || icon === "/icons/grok.svg";
|
||||
return icon ? <img src={icon} alt="" className={cn("size-3.5 shrink-0", monochrome && "dark:invert")} /> : <Cpu className="size-3.5 shrink-0 opacity-70" />;
|
||||
}
|
||||
|
||||
export function resolveModelIcon(model: string) {
|
||||
const name = model.toLowerCase();
|
||||
if (name.includes("claude") || name.includes("anthropic")) return "/icons/claude.svg";
|
||||
// Flow2API 短名:Nano Banana / Imagen / Veo / Omni 均属 Google Gemini 系。
|
||||
if (name.includes("gemini") || name.includes("google") || name.includes("nano banana") || name.includes("nanobanana") || name.includes("imagen") || name.includes("veo") || name.includes("omni flash") || name.includes("omni-flash")) {
|
||||
return "/icons/gemini.svg";
|
||||
}
|
||||
if (name.includes("gpt") || name.includes("openai") || name.includes("dall-e") || name.includes("dalle")) return "/icons/openai.svg";
|
||||
if (name.includes("grok")) return "/icons/grok.svg";
|
||||
if (name.includes("deepseek")) return "/icons/deepseek.svg";
|
||||
if (name.includes("glm") || name.includes("chatglm")) return "/icons/glm.svg";
|
||||
return "";
|
||||
export function ModelIcon({ config, model, icon }: { config?: AiConfig; model?: string; icon?: string }) {
|
||||
return <ModelLogo icon={icon || (config && model ? modelIcon(config, model) : "")} size={14} className="opacity-80" />;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function CapabilityCardPicker({ value, onChange, density = "comfortable"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-col rounded-md border text-left outline-none transition-colors focus-visible:ring-1 focus-visible:ring-foreground/40",
|
||||
"relative flex min-w-0 flex-col rounded-md border text-left outline-none focus-visible:ring-1 focus-visible:ring-foreground/40",
|
||||
density === "compact" ? "min-h-16 p-2" : "min-h-24 p-2.5",
|
||||
selected ? "border-foreground/70 bg-foreground/[.04]" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
@@ -56,7 +56,7 @@ export function CapabilityCardPicker({ value, onChange, density = "comfortable"
|
||||
export function ProtocolCardPicker({ capability, value, onChange, density = "comfortable" }: { capability?: ModelCapabilityChoice; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void; density?: PickerDensity }) {
|
||||
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
|
||||
return (
|
||||
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2", density === "compact" && "gap-1.5")} role="radiogroup" aria-label="模型请求协议">
|
||||
<div className={cn("grid grid-cols-1 gap-2 sm:grid-cols-2", density === "compact" && "gap-1.5 xl:grid-cols-3")} role="radiogroup" aria-label="模型请求协议">
|
||||
{protocols.map((protocol) => {
|
||||
const selected = value === protocol.value;
|
||||
return (
|
||||
@@ -66,7 +66,7 @@ export function ProtocolCardPicker({ capability, value, onChange, density = "com
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-w-0 flex-col rounded-md border text-left outline-none transition-colors focus-visible:ring-1 focus-visible:ring-foreground/40",
|
||||
"relative flex min-w-0 flex-col rounded-md border text-left outline-none focus-visible:ring-1 focus-visible:ring-foreground/40",
|
||||
density === "compact" ? "min-h-16 p-2" : "min-h-24 p-2.5",
|
||||
selected ? "border-foreground/70 bg-foreground/[.04]" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user