feat(*): 模型能力配置 - 支持模型级视频限制校验与协议管理
This commit is contained in:
@@ -164,7 +164,7 @@ export default function ChannelsPage() {
|
||||
<TableSurface>
|
||||
{loading && channels.length === 0 ? <AdminTableSkeleton rows={8} columns={6} /> : <Table className="app-data-table" size="middle" rowKey="id" loading={loading} columns={columns} dataSource={channels} locale={{ emptyText: <AdminTableEmpty filtered={hasFilters} title={hasFilters ? undefined : "还没有系统渠道"} description={hasFilters ? undefined : "创建渠道并配置模型后,普通用户即可使用系统模型。"} action={hasFilters ? undefined : <Button type="primary" icon={<Plus className="size-4" />} onClick={() => openDrawer()}>新增系统渠道</Button>} /> }} pagination={{ current: page, pageSize, total, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (value, range) => `${range[0]}-${range[1]} / 共 ${value} 条`, onChange: (nextPage, nextSize) => updateUrl({ page: nextSize !== pageSize ? 1 : nextPage, pageSize: nextSize }) }} scroll={{ x: 820 }} />}
|
||||
</TableSurface>
|
||||
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} size="min(560px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
|
||||
<Drawer title={editingChannel ? "编辑系统渠道" : "新增系统渠道"} open={drawerOpen} size="min(720px, 100vw)" onClose={closeDrawer} maskClosable={!saving} destroyOnHidden extra={<Button type="primary" loading={saving} onClick={() => void save()}>保存</Button>}>
|
||||
<Form form={form} layout="vertical" requiredMark={false}>
|
||||
<Form.Item name="name" label="渠道名称" rules={[{ required: true, message: "请填写渠道名称" }]}><Input placeholder="例如:OpenAI 官方渠道" /></Form.Item>
|
||||
<Form.Item name="baseUrl" label="Base URL" rules={[{ required: true, message: "请填写 Base URL" }]}><Input placeholder="填写渠道 Base URL" /></Form.Item>
|
||||
|
||||
@@ -125,7 +125,7 @@ export function AdminPageFrame({ title, description, actions, back, children }:
|
||||
|
||||
function MobileAdminNavigation() {
|
||||
return (
|
||||
<nav className="hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 bg-background px-3 py-2 lg:hidden" aria-label="管理后台分区">
|
||||
<nav className="app-workspace-navigation hide-scrollbar flex shrink-0 gap-1 overflow-x-auto border-b border-border/70 px-3 py-2 lg:hidden" aria-label="管理后台分区">
|
||||
{adminNavigation.flatMap((group) => group.items).map((item) => (
|
||||
<NavLink key={item.path} to={item.path} end={item.path === "/admin"} className={({ isActive }) => cn("app-workspace-nav-link flex h-8 shrink-0 items-center gap-1.5 rounded-md px-2.5 text-xs transition-colors", isActive ? "is-active font-medium" : "text-foreground/60 hover:bg-foreground/[.05] hover:text-foreground")}>
|
||||
{item.icon}<span>{item.label}</span>
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { App, Button, Drawer, Form, Input, InputNumber, Popconfirm, Segmented, Select, Space, Switch, Table, Tag } from "antd";
|
||||
import type { ColumnsType } from "antd/es/table";
|
||||
import { AudioLines, Check, Film, Flame, FlaskConical, Image, MessageSquareText, Network, Plus, RefreshCw, Search, Sparkles, Trash2 } from "lucide-react";
|
||||
import { FlaskConical, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
|
||||
import { ListToolbar, TableSurface } from "@/components/layout/workspace-page";
|
||||
import { ModelIcon } from "@/components/model-picker";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol, type ModelProtocolDefinition } from "@/lib/model-protocols";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { CapabilityCardPicker, ProtocolCardPicker, type ModelCapabilityChoice } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig, type ModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { MODEL_PROTOCOLS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { createAdminChannelModel, deleteAdminChannelModel, fetchAdminChannelModels, listAdminChannelModels, testAdminChannelModel, updateAdminChannelModel, type ChannelModel } from "@/services/api/wallet";
|
||||
import type { ModelChannel } from "@/stores/use-config-store";
|
||||
import { AdminPageFrame } from "./admin-shell";
|
||||
|
||||
type EditableCapability = Exclude<ChannelModel["capability"], "">;
|
||||
type EditableCapability = ModelCapabilityChoice;
|
||||
|
||||
type FormValues = {
|
||||
modelKey: string;
|
||||
@@ -24,6 +26,7 @@ type FormValues = {
|
||||
outputTokenPrice: number;
|
||||
cachedTokenPrice: number;
|
||||
enabled: boolean;
|
||||
capabilityConfig?: ModelCapabilityConfig;
|
||||
};
|
||||
|
||||
export function ChannelModelManager({ channel, onClose, onChanged }: { channel: ModelChannel; onClose: () => void; onChanged: () => void | Promise<void> }) {
|
||||
@@ -86,13 +89,13 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
|
||||
const startCreate = () => {
|
||||
setEditing(null);
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true });
|
||||
form.setFieldsValue({ modelKey: "", displayName: "", capability: "text", protocol: "chat-completion", billingMode: "fixed_request", unitPrice: 0, inputTokenPrice: 0, outputTokenPrice: 0, cachedTokenPrice: 0, enabled: true, capabilityConfig: undefined });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = (item: ChannelModel) => {
|
||||
setEditing(item);
|
||||
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled });
|
||||
form.setFieldsValue({ modelKey: item.modelKey, displayName: item.displayName, capability: item.capability || undefined, protocol: item.protocol, billingMode: item.billingMode, unitPrice: item.unitPriceMicrocredits / 1_000_000, inputTokenPrice: item.inputTokenPriceMicrocredits / 1_000_000, outputTokenPrice: item.outputTokenPriceMicrocredits / 1_000_000, cachedTokenPrice: item.cachedTokenPriceMicrocredits / 1_000_000, enabled: item.enabled, capabilityConfig: item.capability === "video" ? item.capabilityConfig || defaultModelCapabilityConfig(item.protocol) : undefined });
|
||||
setEditorOpen(true);
|
||||
};
|
||||
|
||||
@@ -112,6 +115,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
cachedTokenPriceMicrocredits: Math.round((values.cachedTokenPrice || 0) * 1_000_000),
|
||||
priceConfigured: true,
|
||||
enabled: values.enabled !== false,
|
||||
capabilityConfig: values.capability === "video" ? values.capabilityConfig : undefined,
|
||||
};
|
||||
if (editing) await updateAdminChannelModel(channel.id, editing.id, payload);
|
||||
else await createAdminChannelModel(channel.id, payload);
|
||||
@@ -128,13 +132,14 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
};
|
||||
|
||||
const testModel = async () => {
|
||||
const values = await form.validateFields(["modelKey", "capability", "protocol"]);
|
||||
const values = await form.validateFields(["modelKey", "capability", "protocol", ...(modelCapability === "video" ? ["capabilityConfig"] : [])]);
|
||||
setTesting(true);
|
||||
try {
|
||||
const result = await testAdminChannelModel(channel.id, {
|
||||
modelKey: values.modelKey.trim(),
|
||||
capability: values.capability,
|
||||
protocol: values.protocol,
|
||||
capabilityConfig: values.capabilityConfig,
|
||||
});
|
||||
message.success(`模型测试通过,耗时 ${(result.durationMs / 1000).toFixed(2)} 秒`);
|
||||
} catch (error) {
|
||||
@@ -156,6 +161,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
};
|
||||
|
||||
const handleFormValuesChange = (changed: Partial<FormValues>) => {
|
||||
if (changed.protocol && modelCapability === "video") {
|
||||
form.setFieldValue("capabilityConfig", defaultModelCapabilityConfig(changed.protocol).video ? defaultModelCapabilityConfig(changed.protocol) : undefined);
|
||||
}
|
||||
if (!changed.capability) return;
|
||||
const currentBillingMode = form.getFieldValue("billingMode") as ChannelModel["billingMode"] | undefined;
|
||||
if ((currentBillingMode === "per_second" && changed.capability !== "video") || (currentBillingMode === "token" && changed.capability !== "text")) {
|
||||
@@ -163,7 +171,9 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
}
|
||||
const current = form.getFieldValue("protocol") as ModelProtocol | undefined;
|
||||
if (modelProtocolCapability(current) !== changed.capability) {
|
||||
form.setFieldValue("protocol", MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value);
|
||||
const nextProtocol = MODEL_PROTOCOLS.find((item) => item.capability === changed.capability)?.value;
|
||||
form.setFieldValue("protocol", nextProtocol);
|
||||
form.setFieldValue("capabilityConfig", changed.capability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -246,6 +256,7 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
<Form.Item name="protocol" label="请求协议" rules={[{ required: true, message: "请选择模型请求协议" }]}>
|
||||
<ProtocolCardPicker capability={modelCapability} />
|
||||
</Form.Item>
|
||||
{modelCapability === "video" ? <Form.Item name="capabilityConfig" rules={[{ required: true, message: "请配置视频能力参数" }]}><ModelCapabilityEditor protocol={form.getFieldValue("protocol")} /></Form.Item> : null}
|
||||
<Form.Item name="billingMode" label="计费方式" rules={[{ required: true }]}>
|
||||
<Segmented block options={[{ label: "按次计费", value: "fixed_request" }, { label: "按秒计费", value: "per_second", disabled: modelCapability !== "video" }, { label: "Token 计费", value: "token", disabled: modelCapability !== "text" }]} />
|
||||
</Form.Item>
|
||||
@@ -282,118 +293,6 @@ export function ChannelModelManager({ channel, onClose, onChanged }: { channel:
|
||||
);
|
||||
}
|
||||
|
||||
const capabilityChoices: Array<{
|
||||
value: EditableCapability;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: ReactNode;
|
||||
brands: string[];
|
||||
}> = [
|
||||
{ value: "text", label: "文本", description: "对话与推理", icon: <MessageSquareText className="size-4" />, brands: ["openai", "deepseek", "glm"] },
|
||||
{ value: "image", label: "图片", description: "生成与编辑", icon: <Image className="size-4" />, brands: ["openai", "gemini"] },
|
||||
{ value: "video", label: "视频", description: "生成与续写", icon: <Film className="size-4" />, brands: ["grok", "gemini"] },
|
||||
{ value: "audio", label: "音频", description: "语音与音效", icon: <AudioLines className="size-4" />, brands: ["openai"] },
|
||||
];
|
||||
|
||||
function CapabilityCardPicker({ value, onChange }: { value?: EditableCapability; onChange?: (value: EditableCapability) => void }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4" role="radiogroup" aria-label="模型能力">
|
||||
{capabilityChoices.map((item) => {
|
||||
const selected = value === item.value;
|
||||
return (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => onChange?.(item.value)}
|
||||
>
|
||||
<span className={cn("grid size-8 place-items-center rounded-md", selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/65")}>{item.icon}</span>
|
||||
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
|
||||
<span className="mt-2 block text-sm font-semibold">{item.label}</span>
|
||||
<span className="block text-xs text-foreground/48">{item.description}</span>
|
||||
<BrandIconRow models={item.brands} className="mt-auto pt-2" />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolCardPicker({ capability, value, onChange }: { capability?: EditableCapability; value?: ModelProtocol; onChange?: (value: ModelProtocol) => void }) {
|
||||
const protocols = MODEL_PROTOCOLS.filter((item) => item.capability === capability);
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2" role="radiogroup" aria-label="模型请求协议">
|
||||
{protocols.map((protocol) => {
|
||||
const selected = value === protocol.value;
|
||||
return (
|
||||
<button
|
||||
key={protocol.value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
className={cn(
|
||||
"relative flex min-h-28 min-w-0 flex-col rounded-md border p-3 text-left outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
||||
selected ? "border-primary/70 bg-primary/5" : "border-border/75 bg-background hover:border-foreground/25 hover:bg-muted/30",
|
||||
)}
|
||||
onClick={() => onChange?.(protocol.value)}
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-2.5 pr-6">
|
||||
<ProtocolBrandMark protocol={protocol} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{protocol.label}</div>
|
||||
<div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/48">{protocol.create}</div>
|
||||
</div>
|
||||
</div>
|
||||
{selected ? <span className="absolute right-2.5 top-2.5 grid size-5 place-items-center rounded-full bg-primary text-primary-foreground"><Check className="size-3" /></span> : null}
|
||||
<div className="mt-2 line-clamp-2 text-xs leading-5 text-foreground/58">{protocol.media}</div>
|
||||
<div className="mt-auto flex items-center justify-between gap-2 pt-2 text-[var(--fs-tiny)] text-foreground/42">
|
||||
<span className="truncate">{protocol.contentType}</span>
|
||||
{protocol.poll ? <span className="shrink-0">异步轮询</span> : <span className="shrink-0">同步响应</span>}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProtocolBrandMark({ protocol }: { protocol: ModelProtocolDefinition }) {
|
||||
if (protocol.value === "chat-completion") return <BrandIconRow models={["openai", "deepseek", "glm"]} compact />;
|
||||
if (protocol.value.startsWith("volcengine-jimeng-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Sparkles className="size-4" /></span>;
|
||||
if (protocol.value.startsWith("volcengine-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Flame className="size-4" /></span>;
|
||||
if (protocol.value.startsWith("newapi-channel-")) return <span className="grid size-8 shrink-0 place-items-center rounded-md bg-muted text-foreground/65"><Network className="size-4" /></span>;
|
||||
const brand = protocol.value === "gemini-veo" ? "gemini" : protocol.value === "xai-video" ? "grok" : "openai";
|
||||
return <BrandIconRow models={[brand]} compact />;
|
||||
}
|
||||
|
||||
function BrandIconRow({ models, compact = false, className }: { models: string[]; compact?: boolean; className?: string }) {
|
||||
return (
|
||||
<span className={cn("flex items-center", compact ? "shrink-0 -space-x-1" : "-space-x-1", className)} aria-hidden="true">
|
||||
{models.map((model) => (
|
||||
<span key={model} className={cn("grid shrink-0 place-items-center rounded-md border border-border/70 bg-background", compact ? "size-8" : "size-6")} title={modelBrandLabel(model)}>
|
||||
<ModelIcon model={model} />
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function modelBrandLabel(model: string) {
|
||||
const labels: Record<string, string> = {
|
||||
openai: "OpenAI",
|
||||
deepseek: "DeepSeek",
|
||||
glm: "智谱 GLM",
|
||||
gemini: "Google Gemini",
|
||||
grok: "xAI Grok",
|
||||
};
|
||||
return labels[model] || model;
|
||||
}
|
||||
|
||||
function capabilityLabel(value: ChannelModel["capability"]) {
|
||||
return { text: "文本", image: "图片", video: "视频", audio: "音频", "": "待配置" }[value];
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export default function AdminPage() {
|
||||
if (!hydrated) return null;
|
||||
if (actor?.role !== "admin") {
|
||||
return (
|
||||
<main className="min-h-dvh bg-background px-6 py-10 text-foreground">
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-border bg-background p-6">
|
||||
<main className="app-workspace-page min-h-dvh px-6 py-10 text-foreground">
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-border bg-[var(--workspace-surface)] p-6">
|
||||
<h1 className="text-2xl font-semibold">无权限</h1>
|
||||
<p className="mt-2 text-sm text-foreground/55">当前账号不是管理员,无法访问后台。</p>
|
||||
</div>
|
||||
|
||||
@@ -1684,7 +1684,7 @@ function InfiniteCanvasPage() {
|
||||
) : null}
|
||||
|
||||
{dialogNode && dialogNode.type !== CanvasNodeType.Script && dialogNode.type !== CanvasNodeType.Drawing && !selectionBox ? (
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef}>
|
||||
<CanvasNodePanelOverlay node={dialogNode} viewport={viewport} containerRef={containerRef} panelWidth={624}>
|
||||
{renderCanvasNodePanel(dialogNode)}
|
||||
</CanvasNodePanelOverlay>
|
||||
) : null}
|
||||
|
||||
@@ -38,6 +38,9 @@ export function creationAttachmentFromImage(file: File, uploaded: UploadedImage)
|
||||
dataUrl: uploaded.url,
|
||||
url: uploaded.url,
|
||||
storageKey: uploaded.storageKey,
|
||||
bytes: uploaded.bytes,
|
||||
width: uploaded.width,
|
||||
height: uploaded.height,
|
||||
previewUrl: uploaded.url,
|
||||
};
|
||||
}
|
||||
@@ -51,6 +54,9 @@ export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachme
|
||||
dataUrl: url,
|
||||
url,
|
||||
storageKey: asset.data.storageKey,
|
||||
bytes: asset.data.bytes,
|
||||
width: asset.data.width,
|
||||
height: asset.data.height,
|
||||
previewUrl: url,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-referen
|
||||
import { createClientId } from "@/lib/client-id";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
import { VIDEO_RESOLUTION_OPTIONS } from "@/lib/video-generation-options";
|
||||
import { modelCapabilityConfigFor, normalizeVideoValue, videoDurationAllowed, videoDurationOptions, type VideoCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { parseBackendGenerationResult, runBackendGenerationTask, runBackendGenerationTaskBatch, type BackendGenerationResult } from "@/services/api/generation-task";
|
||||
import { requestImageQuestion } from "@/services/api/image";
|
||||
import { listAddedSkills, type Skill } from "@/services/api/skills";
|
||||
@@ -128,11 +129,20 @@ export default function CreatePage() {
|
||||
[activeId, conversations],
|
||||
);
|
||||
const selectedModel = mode === "text" ? config.textModel : mode === "image" ? config.imageModel : config.videoModel;
|
||||
const videoProfile = useMemo(() => modelCapabilityConfigFor(config, selectedModel).video!, [config, selectedModel]);
|
||||
const mentionReferences = useMemo(() => buildCreationMentionReferences(addedSkills, attachments, draftReferences), [addedSkills, attachments, draftReferences]);
|
||||
const isEmpty = !activeConversation?.messages.length;
|
||||
const pendingMediaKey = useMemo(() => pendingCreationMediaKey(conversations), [conversations]);
|
||||
const pendingTaskIds = useMemo(() => pendingCreationTaskIds(conversations), [conversations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "video") return;
|
||||
const normalized = normalizeVideoValue(videoProfile, { seconds, ratio, resolution: `${videoQuality}p` });
|
||||
setSeconds(normalized.seconds);
|
||||
setRatio(normalized.ratio);
|
||||
setVideoQuality(normalized.resolution.replace(/p$/i, ""));
|
||||
}, [mode, selectedModel, videoProfile]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void localforage.getItem<CreationConversation[]>(STORAGE_KEY).then((stored) => {
|
||||
@@ -228,7 +238,12 @@ export default function CreatePage() {
|
||||
};
|
||||
|
||||
const addAttachments = (files: FileList | File[]) => {
|
||||
const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, 6 - attachments.length));
|
||||
const maxImages = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : 6;
|
||||
if (mode === "video" && maxImages === 0) {
|
||||
toast.warning("当前模型不支持图生视频");
|
||||
return;
|
||||
}
|
||||
const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, maxImages - attachments.length));
|
||||
if (!next.length) return;
|
||||
void Promise.allSettled(next.map(async (file) => {
|
||||
const uploaded = await uploadImage(file);
|
||||
@@ -238,7 +253,7 @@ export default function CreatePage() {
|
||||
})).then((settled) => {
|
||||
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
|
||||
const failed = settled.filter((entry) => entry.status === "rejected");
|
||||
if (items.length) setAttachments((current) => [...current, ...items].slice(0, 6));
|
||||
if (items.length) setAttachments((current) => [...current, ...items].slice(0, mode === "video" ? videoProfile.references.maxImages : 6));
|
||||
if (failed.length) toast.error(`${failed.length} 张参考图片上传失败,请重试`);
|
||||
});
|
||||
};
|
||||
@@ -261,9 +276,21 @@ export default function CreatePage() {
|
||||
toast.warning(`请先在设置中配置${modeLabels[mode]}模型`);
|
||||
return;
|
||||
}
|
||||
if (mode === "video" && !videoDurationAllowed(videoProfile, Number(seconds))) {
|
||||
toast.error("当前模型不支持所选视频时长,请重新选择");
|
||||
return;
|
||||
}
|
||||
if (mode === "video" && Array.from(text).length > videoProfile.references.promptMaxChars) {
|
||||
toast.error(`提示词超过当前模型限制(最多 ${videoProfile.references.promptMaxChars} 字)`);
|
||||
return;
|
||||
}
|
||||
const settings = { ratio, seconds, quality, videoQuality, count };
|
||||
const references = selectedCreationReferences(text, mentionReferences);
|
||||
const expandedPrompt = expandCreationPrompt(text, references, attachments);
|
||||
if (mode === "video" && Array.from(expandedPrompt).length > videoProfile.references.promptMaxChars) {
|
||||
toast.error(`引用展开后提示词超过当前模型限制(最多 ${videoProfile.references.promptMaxChars} 字)`);
|
||||
return;
|
||||
}
|
||||
const referenceMetadata = creationReferenceMetadata(references);
|
||||
followLatestMessageRef.current = true;
|
||||
const userMessage = newMessage("user", text, { mode, model: selectedModel, attachments, references, settings });
|
||||
@@ -446,6 +473,7 @@ export default function CreatePage() {
|
||||
onFileChange: handleFileChange,
|
||||
onModeChange: selectMode,
|
||||
model: selectedModel,
|
||||
videoProfile,
|
||||
config,
|
||||
onModelChange: (value: string) => updateConfig(mode === "text" ? "textModel" : mode === "image" ? "imageModel" : "videoModel", value),
|
||||
ratio,
|
||||
@@ -485,7 +513,7 @@ export default function CreatePage() {
|
||||
}
|
||||
|
||||
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) {
|
||||
return <Drawer open={open} onClose={onClose} placement="right" size="min(360px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
return <Drawer open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
|
||||
<ol className="creation-history-timeline" aria-label="历史对话,按更新时间倒序排列">
|
||||
{conversations.map((conversation) => {
|
||||
const latest = conversationPreviewMessage(conversation);
|
||||
@@ -553,6 +581,7 @@ type ComposerProps = {
|
||||
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
|
||||
onModeChange: (mode: CreationMode) => void;
|
||||
model: string;
|
||||
videoProfile: VideoCapabilityConfig;
|
||||
config: ReturnType<typeof useEffectiveConfig>;
|
||||
onModelChange: (value: string) => void;
|
||||
ratio: string;
|
||||
@@ -580,9 +609,9 @@ function CreationComposer(props: ComposerProps) {
|
||||
return <section className={`creation-chat-composer is-${props.variant}`}>
|
||||
<div className="creation-chat-writing-surface">
|
||||
<input ref={props.fileInputRef} type="file" hidden accept="image/*" multiple onChange={props.onFileChange} />
|
||||
<Tooltip title="添加参考图片"><button type="button" className="creation-chat-reference is-paper" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy} aria-label="添加参考图片"><Plus /><span>参考内容</span></button></Tooltip>
|
||||
<Tooltip title={props.mode === "video" && !props.videoProfile.operations.includes("image_to_video") ? "当前模型不支持图生视频" : "添加参考图片"}><button type="button" className="creation-chat-reference is-paper" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || (props.mode === "video" && !props.videoProfile.operations.includes("image_to_video"))} aria-label="添加参考图片"><Plus /><span>参考内容</span></button></Tooltip>
|
||||
<div className="creation-chat-editor">
|
||||
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} maxLength={props.mode === "video" ? props.videoProfile.references.promptMaxChars : undefined} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
|
||||
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment"><img src={item.previewUrl} alt={item.name} /><button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
@@ -591,7 +620,7 @@ function CreationComposer(props: ComposerProps) {
|
||||
<ModePicker mode={props.mode} onModeChange={props.onModeChange} />
|
||||
<ModelPicker config={props.config} value={props.model} onChange={props.onModelChange} capability={props.mode} className="creation-model-picker" placeholder={`选择${modeLabels[props.mode]}模型`} showSelectedPrice={false} variant="creation" />
|
||||
{props.mode !== "text" ? <GenerationSettingsMenu {...props} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu model={props.model} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
{props.mode === "video" ? <DurationMenu profile={props.videoProfile} seconds={props.seconds} onChange={props.setSeconds} /> : null}
|
||||
</div>
|
||||
{props.busy ? <button type="button" className="creation-chat-submit is-stopping" onClick={props.onStop} aria-label="停止生成"><Square className="size-3.5 fill-current" /></button> : <button type="button" className="creation-chat-submit" disabled={!canSubmit} onClick={props.onSubmit} aria-label="发送"><ArrowUp className="size-4" /></button>}
|
||||
</footer>
|
||||
@@ -615,10 +644,12 @@ function GenerationSettingsMenu(props: ComposerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [customRatioOpen, setCustomRatioOpen] = useState(!ratioOptions.some((option) => option.value === props.ratio));
|
||||
const qualityLabel = qualityOptions.find((item) => item.value === props.quality)?.label || "自动";
|
||||
const ratios = props.mode === "video" ? props.videoProfile.ratios : ratioOptions.map((item) => item.value);
|
||||
const resolutions = props.mode === "video" ? props.videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: videoResolutionLabel(value) })) : resolutionOptions;
|
||||
const summary = props.mode === "video" ? `${props.ratio} · ${videoResolutionLabel(props.videoQuality)}` : `${props.ratio} · ${qualityLabel} · ${props.count}`;
|
||||
const panel = <div className="creation-parameter-menu">
|
||||
<SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratioOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.ratio} className={option.value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(option.value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(option.value)} /></span><span>{option.value}</span></button>)}</div>{customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>}</div></SettingSection>
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutionOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
<SettingSection title="画幅" value={props.ratio}><div className="creation-parameter-content"><div className="creation-choice-grid is-ratio">{ratios.map((value) => <button key={value} type="button" aria-pressed={value === props.ratio} className={value === props.ratio ? "is-selected" : ""} onClick={() => { props.setRatio(value); setCustomRatioOpen(false); }}><span className="creation-ratio-preview"><span style={ratioPreviewStyle(value)} /></span><span>{value}</span></button>)}</div>{props.mode !== "video" && (customRatioOpen ? <label className="creation-custom-value"><span>宽 : 高</span><input value={props.ratio} onFocus={(event) => event.currentTarget.select()} onChange={(event) => props.setRatio(event.target.value)} placeholder="1920x1080 或 2:1" aria-label="自定义画幅,支持宽x高或比例" /></label> : <button type="button" className="creation-custom-trigger" onClick={() => setCustomRatioOpen(true)}><Plus />输入自定义比例</button>)}</div></SettingSection>
|
||||
{props.mode === "video" ? <SettingSection title="清晰度" value={videoResolutionLabel(props.videoQuality)}><div className="creation-choice-grid is-resolution">{resolutions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.videoQuality} className={option.value === props.videoQuality ? "is-selected" : ""} onClick={() => props.setVideoQuality(option.value)}>{option.label}</button>)}</div></SettingSection> : <>
|
||||
<SettingSection title="图片质量" value={qualityLabel}><div className="creation-choice-grid is-quality">{qualityOptions.map((option) => <button key={option.value} type="button" aria-pressed={option.value === props.quality} className={option.value === props.quality ? "is-selected" : ""} onClick={() => props.setQuality(option.value)}><span>{option.label}</span><small>{option.description}</small></button>)}</div></SettingSection>
|
||||
<SettingSection title="生成数量" value={`${props.count} 张`}><div className="creation-parameter-content"><div className="creation-choice-grid is-count">{countOptions.map((option) => <button key={option} type="button" aria-pressed={option === props.count} className={option === props.count ? "is-selected" : ""} onClick={() => props.setCount(option)}>{option}</button>)}</div><label className="creation-custom-value"><span>自定义</span><input inputMode="numeric" pattern="[0-9]*" value={props.count} onChange={(event) => props.setCount(event.target.value)} aria-label="生成数量,范围 1 到 15" /><em>张</em></label></div></SettingSection>
|
||||
</>}
|
||||
@@ -632,11 +663,13 @@ function SettingSection({ title, value, children }: { title: string; value?: str
|
||||
return <section className="creation-parameter-section"><header><h3>{title}</h3>{value ? <span>{value}</span> : null}</header>{children}</section>;
|
||||
}
|
||||
|
||||
function DurationMenu({ model, seconds, onChange }: { model: string; seconds: string; onChange: (value: string) => void }) {
|
||||
function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapabilityConfig; seconds: string; onChange: (value: string) => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const value = Math.max(1, Math.floor(Number(seconds) || 6));
|
||||
const presets = durationPresets(model);
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div><div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div><label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min="1" step="1" inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label></div>}>
|
||||
const value = Math.max(1, Math.floor(Number(seconds) || profile.duration.default));
|
||||
const presets = videoDurationOptions(profile);
|
||||
const min = profile.duration.selection === "range" ? profile.duration.min || 1 : Math.min(...presets);
|
||||
const max = profile.duration.selection === "range" ? profile.duration.max || min : Math.max(...presets);
|
||||
return <Popover open={open} onOpenChange={setOpen} trigger="click" placement="bottom" arrow={false} classNames={{ root: "creation-control-popover", container: "creation-control-popover-surface", content: "creation-control-popover-content" }} content={<div className="creation-duration-menu"><div className="creation-duration-heading"><span>时长</span><strong>{value} 秒</strong></div><div className="creation-duration-choices">{presets.map((item) => <button key={item} type="button" className={item === value ? "is-selected" : ""} onClick={() => onChange(String(item))}>{item}s</button>)}</div>{profile.duration.selection === "range" ? <label className="creation-custom-value is-duration"><span>自定义时长</span><span className="creation-duration-custom-field"><input type="number" min={min} max={max} step={profile.duration.step || 1} inputMode="numeric" value={seconds} onFocus={(event) => event.currentTarget.select()} onBlur={() => onChange(String(value))} onChange={(event) => onChange(event.target.value)} aria-label="自定义视频时长,单位秒" /><em>秒</em></span></label> : null}</div>}>
|
||||
<button type="button" className="creation-chat-control is-duration" aria-label={`视频时长:${value}秒`}><Clock3 /><span>{value}s</span><ChevronDown className={open ? "is-open" : ""} /></button>
|
||||
</Popover>;
|
||||
}
|
||||
@@ -646,13 +679,6 @@ function CreationIntro({ mode }: { mode: CreationMode }) {
|
||||
return <header className="creation-chat-intro" aria-live="polite"><span className="creation-intro-signal" aria-hidden="true" /><h1>{copy[0]}<span>{copy[1]}</span>{copy[2]}</h1><p>{copy[3]}</p></header>;
|
||||
}
|
||||
|
||||
function durationPresets(model: string) {
|
||||
const name = modelOptionName(model).toLowerCase();
|
||||
if (name.includes("veo")) return [4, 6, 8];
|
||||
if (name.includes("seedance")) return [4, 5, 8, 10, 15];
|
||||
return [5, 10, 15, 20, 30];
|
||||
}
|
||||
|
||||
function videoResolutionLabel(value: string | number) {
|
||||
return Number(String(value).replace(/p$/i, "")) === 2160 ? "4K" : `${String(value).replace(/p$/i, "")}P`;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex h-dvh flex-col overflow-hidden bg-background text-foreground">
|
||||
<main className="flex h-full min-h-0 items-center justify-center overflow-y-auto bg-background px-6 py-10 text-stone-900 dark:text-stone-100">
|
||||
<div className="app-workspace-page flex h-dvh flex-col overflow-hidden text-foreground">
|
||||
<main className="app-workspace-page flex h-full min-h-0 items-center justify-center overflow-y-auto px-6 py-10 text-foreground">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<WorkspaceSignalIcon variant="empty" size="lg" className="mx-auto mb-5" />
|
||||
<div className="mb-2 text-xs font-semibold tabular-nums text-foreground/45">404</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ export default function RouteErrorPage() {
|
||||
const message = error instanceof Error ? error.message : "页面暂时无法显示";
|
||||
|
||||
return (
|
||||
<main className="grid h-dvh place-items-center bg-background px-6 text-foreground">
|
||||
<main className="app-workspace-page grid h-dvh place-items-center px-6 text-foreground">
|
||||
<section className="w-full max-w-md text-center">
|
||||
<WorkspaceSignalIcon variant="error" size="lg" className="mx-auto" />
|
||||
<p className="text-xs font-medium text-muted-foreground">页面运行异常</p>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { App, Button, InputNumber, Segmented, Select } from "antd";
|
||||
import { App, Button, InputNumber, Segmented } from "antd";
|
||||
import { FlaskConical } from "lucide-react";
|
||||
|
||||
import { testChannelModelConnection } from "@/lib/model-connection-test";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolCapability, modelProtocolDefinition, modelProtocolLabel, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { ModelCapabilityEditor } from "@/components/model-capability-editor";
|
||||
import { ModelCapabilityProtocolModal } from "@/components/model-protocol-picker";
|
||||
import { defaultModelCapabilityConfig } from "@/lib/model-capabilities";
|
||||
import { modelProtocolCapability, modelProtocolDefinition, type ModelProtocol } from "@/lib/model-protocols";
|
||||
import { modelMatchesCapability, modelOptionName, type ModelChannel } from "@/stores/use-config-store";
|
||||
|
||||
type ModelCost = NonNullable<ModelChannel["modelCosts"]>[number];
|
||||
@@ -20,6 +23,7 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
protocol: defaultProtocolForModel(channel, model),
|
||||
billingMode: "fixed_request" as const,
|
||||
unitPriceMicrocredits: 0,
|
||||
capabilityConfig: defaultModelCapabilityConfig(defaultProtocolForModel(channel, model)),
|
||||
};
|
||||
const next = [...(channel.modelCosts || []).filter((item) => item.model !== model), { ...current, ...patch, model }];
|
||||
onChange(next.filter((item) => channel.models.includes(item.model)));
|
||||
@@ -40,10 +44,10 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
return (
|
||||
<div className="mt-3 border-t border-border/70 pt-3">
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div><div className="text-xs font-medium">模型请求协议</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">测试会发起真实请求并可能产生供应商费用;视频价格仅用于成本记录</div></div>
|
||||
<div><div className="text-xs font-medium">模型能力与请求协议</div><div className="mt-0.5 text-[var(--fs-tiny)] text-foreground/42">与运营后台使用同一能力目录;测试会发起真实请求并可能产生供应商费用</div></div>
|
||||
<span className="text-[var(--fs-tiny)] text-foreground/35">{channel.models.length} 个模型</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border/60 rounded-md border border-border/70">
|
||||
<div className="space-y-2">
|
||||
{channel.models.map((rawModel) => {
|
||||
const model = modelOptionName(rawModel);
|
||||
const cost = channel.modelCosts?.find((item) => item.model === model);
|
||||
@@ -51,17 +55,19 @@ export function ChannelModelSettings({ channel, onChange }: { channel: ModelChan
|
||||
const capability = cost?.capability || modelProtocolCapability(protocol) || "text";
|
||||
const billingMode = cost?.billingMode || "fixed_request";
|
||||
return (
|
||||
<div key={model} className="grid gap-2 px-2.5 py-2.5 lg:grid-cols-[minmax(140px,1fr)_minmax(280px,1.6fr)_176px_160px_96px] lg:items-center">
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<Select<ModelProtocol>
|
||||
size="small"
|
||||
value={protocol}
|
||||
options={MODEL_PROTOCOL_OPTIONS}
|
||||
onChange={(value) => updateCost(model, { protocol: value, capability: modelProtocolCapability(value) || capability, billingMode: modelProtocolCapability(value) === "video" ? billingMode : "fixed_request" })}
|
||||
/>
|
||||
{capability === "video" ? <Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /> : <span className="text-center text-[var(--fs-tiny)] text-foreground/35">{modelProtocolLabel(protocol)}</span>}
|
||||
{capability === "video" ? <InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /> : <span />}
|
||||
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
|
||||
<div key={model} className="rounded-md border border-border/70 bg-background/45 p-2.5">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="min-w-0"><div className="truncate text-xs font-medium" title={model}>{model}</div><div className="mt-0.5 truncate font-mono text-[var(--fs-tiny)] text-foreground/40" title={modelProtocolDefinition(protocol)?.create}>{modelProtocolDefinition(protocol)?.create}</div></div>
|
||||
<Button size="small" icon={<FlaskConical className="size-3.5" />} loading={testingModel === model} disabled={Boolean(testingModel && testingModel !== model)} onClick={() => void testModel(model, capability, protocol)}>测试</Button>
|
||||
</div>
|
||||
<div className="mt-2 space-y-2">
|
||||
<ModelCapabilityProtocolModal
|
||||
value={{ capability, protocol }}
|
||||
onChange={({ capability: nextCapability, protocol: nextProtocol }) => updateCost(model, { protocol: nextProtocol, capability: nextCapability, billingMode: nextCapability === "video" ? billingMode : "fixed_request", capabilityConfig: nextCapability === "video" ? defaultModelCapabilityConfig(nextProtocol) : undefined })}
|
||||
/>
|
||||
{capability === "video" ? <div className="grid gap-2 lg:grid-cols-[176px_1fr]"><Segmented size="small" block value={billingMode} options={[{ label: "按次", value: "fixed_request" }, { label: "按秒", value: "per_second" }]} onChange={(value) => updateCost(model, { billingMode: value as ModelCost["billingMode"] })} /><InputNumber size="small" min={0} max={1_000_000} precision={6} step={0.1} className="w-full" placeholder={billingMode === "per_second" ? "每秒价格" : "每次价格"} addonAfter={`积分/${billingMode === "per_second" ? "秒" : "次"}`} value={cost ? cost.unitPriceMicrocredits / 1_000_000 : null} onChange={(value) => updateCost(model, { unitPriceMicrocredits: Math.round(Number(value || 0) * 1_000_000) })} /></div> : null}
|
||||
{capability === "video" ? <ModelCapabilityEditor value={cost?.capabilityConfig || defaultModelCapabilityConfig(protocol)} protocol={protocol} onChange={(capabilityConfig) => updateCost(model, { capabilityConfig })} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Select, Tag, Tooltip } from "antd";
|
||||
import { App, Button, Form, Input, InputNumber, Popconfirm, Segmented, Select, Tag, Tooltip } from "antd";
|
||||
import { ArrowLeft, Boxes, ChevronDown, ChevronUp, CircleCheck, Cloud, Info, MessageSquareText, Plus, RadioTower, RefreshCw, SlidersHorizontal, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router";
|
||||
@@ -8,19 +8,16 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
|
||||
import { ChannelHeadersEditor, validateChannelHeaders } from "@/components/channel-headers-editor";
|
||||
import { refreshSystemChannels } from "@/lib/user-session";
|
||||
import { MODEL_PROTOCOL_OPTIONS, modelProtocolLabel } from "@/lib/model-protocols";
|
||||
import { fetchChannelModels } from "@/services/api/image";
|
||||
import { audioFormatOptions, audioVoiceOptions, normalizeAudioSpeedValue } from "@/lib/audio-generation";
|
||||
import {
|
||||
createModelChannel,
|
||||
defaultBaseUrlForApiFormat,
|
||||
defaultBaseUrlForChannelInterface,
|
||||
defaultConfig,
|
||||
filterModelsByCapability,
|
||||
modelOptionsFromChannels,
|
||||
useConfigStore,
|
||||
type AiConfig,
|
||||
type ChannelInterfaceType,
|
||||
type ModelChannel,
|
||||
} from "@/stores/use-config-store";
|
||||
import { useUserStore } from "@/stores/use-user-store";
|
||||
@@ -38,13 +35,7 @@ const configSections: Array<{ key: ConfigSectionKey; label: string; description:
|
||||
{ key: "storage", label: "我的 OSS", description: "管理个人媒体存储", icon: <Cloud className="size-4" /> },
|
||||
];
|
||||
|
||||
type UserChannelProtocol = ChannelInterfaceType | "auto" | "gemini";
|
||||
|
||||
const channelProtocolOptions = [
|
||||
{ label: "OpenAI 自动兼容", value: "auto" },
|
||||
{ label: "Google Gemini(模型级选择)", value: "gemini" },
|
||||
...MODEL_PROTOCOL_OPTIONS,
|
||||
];
|
||||
type UserChannelConnection = "openai" | "gemini";
|
||||
|
||||
function isConfigSection(value: string | null): value is ConfigSectionKey {
|
||||
return configSections.some((section) => section.key === value);
|
||||
@@ -131,12 +122,12 @@ export default function SettingsPage() {
|
||||
}));
|
||||
};
|
||||
|
||||
const updateChannelProtocol = (channel: ModelChannel, protocol: UserChannelProtocol) => {
|
||||
const apiFormat = protocol === "gemini" || protocol === "gemini-veo" ? "gemini" : "openai";
|
||||
const interfaceType = protocol === "auto" || protocol === "gemini" ? undefined : protocol;
|
||||
const defaultBaseUrl = protocol === "gemini" ? defaultBaseUrlForApiFormat("gemini") : defaultBaseUrlForChannelInterface(interfaceType);
|
||||
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, baseUrl });
|
||||
// 渠道只负责连接类型;具体模型能力和请求协议由下方共享能力卡片维护。
|
||||
updateChannel(channel.id, { apiFormat, interfaceType: undefined, baseUrl });
|
||||
};
|
||||
|
||||
const addChannel = () => {
|
||||
@@ -253,7 +244,7 @@ export default function SettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="flex h-full min-h-0 flex-col bg-[var(--workspace-canvas)] text-foreground">
|
||||
<main className="app-workspace-page flex h-full min-h-0 flex-col text-foreground">
|
||||
<header className="flex min-h-16 shrink-0 items-center justify-between gap-4 border-b border-border/70 px-4 py-3 sm:px-5">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{shouldPromptContinue ? (
|
||||
@@ -290,7 +281,7 @@ export default function SettingsPage() {
|
||||
|
||||
<section className="flex min-h-0 min-w-0 flex-1 flex-col bg-transparent">
|
||||
<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={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-[1180px]"}>
|
||||
<div className={activeTab === "prompts" ? "h-full w-full" : "mx-auto w-full max-w-none"}>
|
||||
{([
|
||||
{
|
||||
key: "channels",
|
||||
@@ -302,7 +293,7 @@ export default function SettingsPage() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex w-fit max-w-full flex-wrap items-center gap-1.5 text-xs text-foreground/65">
|
||||
<Info className="size-3.5 shrink-0" />
|
||||
<span>渠道保存连接和默认协议;拉取模型后,请为需要特殊路径的模型单独选择请求协议。</span>
|
||||
<span>渠道只保存连接类型;拉取模型后,请在每个模型下配置能力与请求协议。</span>
|
||||
<Button type="link" size="small" className="h-auto p-0 text-xs font-semibold" onClick={() => selectSection("models")}>
|
||||
打开模型选择
|
||||
</Button>
|
||||
@@ -324,10 +315,10 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
{userChannels.length ? (
|
||||
<div className="space-y-3">
|
||||
<div className="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-3">
|
||||
<div className="mb-3 flex flex-wrap items-start justify-between gap-3">
|
||||
<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 || "未命名渠道"}
|
||||
@@ -384,12 +375,12 @@ export default function SettingsPage() {
|
||||
onBlur={(event) => updateChannel(channel.id, { name: event.target.value.trim() || "未命名渠道" })}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="默认模型协议" htmlFor={`channel-${channel.id}-protocol`} className="mb-0 lg:col-span-3" extra="用于新模型预填,单个模型可在下方覆盖。">
|
||||
<Select<UserChannelProtocol>
|
||||
id={`channel-${channel.id}-protocol`}
|
||||
value={channelProtocolValue(channel)}
|
||||
options={channelProtocolOptions}
|
||||
onChange={(value) => updateChannelProtocol(channel, value)}
|
||||
<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">
|
||||
@@ -412,6 +403,16 @@ export default function SettingsPage() {
|
||||
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>
|
||||
<Form.Item label="模型列表" htmlFor={`channel-${channel.id}-models`} className="mb-0 lg:col-span-7">
|
||||
<Select
|
||||
id={`channel-${channel.id}-models`}
|
||||
@@ -593,9 +594,8 @@ function uniqueModels(models: string[]) {
|
||||
return Array.from(new Set(models.map((model) => model.trim()).filter(Boolean)));
|
||||
}
|
||||
|
||||
function channelProtocolValue(channel: ModelChannel): UserChannelProtocol {
|
||||
if (channel.apiFormat === "gemini") return "gemini";
|
||||
return channel.interfaceType || "auto";
|
||||
function channelConnectionMode(channel: ModelChannel): UserChannelConnection {
|
||||
return channel.apiFormat === "gemini" ? "gemini" : "openai";
|
||||
}
|
||||
|
||||
function channelConnectionError(channel: ModelChannel) {
|
||||
@@ -607,12 +607,13 @@ function channelConnectionError(channel: ModelChannel) {
|
||||
} catch {
|
||||
return "Base URL 格式不正确";
|
||||
}
|
||||
if (!channel.apiKey.trim()) return "请填写 API Key";
|
||||
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.apiFormat, channel.interfaceType || "auto", JSON.stringify(channel.headers || [])].join("\n");
|
||||
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) {
|
||||
@@ -624,8 +625,8 @@ function isChannelReady(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function focusInvalidChannelField(channel: ModelChannel) {
|
||||
const baseUrlError = channelConnectionError({ ...channel, apiKey: "valid" });
|
||||
const field = baseUrlError ? "base-url" : !channel.apiKey.trim() ? "api-key" : "models";
|
||||
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" });
|
||||
@@ -634,19 +635,7 @@ function focusInvalidChannelField(channel: ModelChannel) {
|
||||
}
|
||||
|
||||
function channelProtocolLabel(channel: ModelChannel) {
|
||||
const protocol = channelProtocolValue(channel);
|
||||
switch (protocol) {
|
||||
case "gemini":
|
||||
return "Gemini 原生";
|
||||
case "chat-completion":
|
||||
return "Chat Completions";
|
||||
case "openai-response":
|
||||
return "OpenAI Responses";
|
||||
case "openai-image":
|
||||
return "OpenAI Images";
|
||||
default:
|
||||
return protocol === "auto" ? "OpenAI 自动兼容" : modelProtocolLabel(protocol);
|
||||
}
|
||||
return channelConnectionMode(channel) === "gemini" ? "Gemini 原生" : "OpenAI 兼容";
|
||||
}
|
||||
|
||||
function isKnownDefaultBaseUrl(value: string) {
|
||||
@@ -654,3 +643,7 @@ function isKnownDefaultBaseUrl(value: string) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/componen
|
||||
import { WorkspaceState } from "@/components/layout/workspace-state";
|
||||
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
|
||||
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
|
||||
import { modelCapabilityConfigFor } from "@/lib/model-capabilities";
|
||||
|
||||
import { cancelGenerationTask, createAgentSession, createGenerationTask, listGenerationTasks, listTaskLogs, queryFailedVideoProviderTask, queryGenerationTask, retryGenerationTask, type CreateTaskInput, type GenerationTask, type TaskLog, type TaskStatus } from "@/services/api/task-center";
|
||||
import { syncGenerationTaskToCanvasStore } from "@/lib/canvas/canvas-generation-task-sync";
|
||||
@@ -676,6 +677,7 @@ function formatTaskJson(value?: string) {
|
||||
|
||||
function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConfig>) {
|
||||
return {
|
||||
channelId: config.channelId,
|
||||
apiFormat: config.apiFormat,
|
||||
interfaceType: config.interfaceType,
|
||||
baseUrl: config.baseUrl,
|
||||
@@ -694,6 +696,7 @@ function backendProviderConfig(config: ReturnType<typeof resolveModelRequestConf
|
||||
audioFormat: config.audioFormat,
|
||||
audioSpeed: config.audioSpeed,
|
||||
audioInstructions: config.audioInstructions,
|
||||
capabilityConfig: modelCapabilityConfigFor(config, config.model),
|
||||
systemPrompt: config.systemPrompt,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user