chore: commit pending gateway changes

This commit is contained in:
2026-05-10 22:34:15 +08:00
parent 53f8edfb67
commit d59756a27c
71 changed files with 15106 additions and 656 deletions
@@ -0,0 +1,433 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, ShieldCheck } from 'lucide-react';
import type {
BaseModelCatalogItem,
GatewayAccessEffect,
GatewayAccessRuleBatchRequest,
GatewayAccessRuleResourceRequest,
GatewayAccessResourceType,
GatewayAccessRule,
IntegrationPlatform,
PlatformModel,
UserGroup,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, Checkbox, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
type Effect = Extract<GatewayAccessEffect, 'allow' | 'deny'>;
type ResourceType = Extract<GatewayAccessResourceType, 'platform' | 'platform_model'>;
type ResourceKey = `${ResourceType}:${string}`;
type PlatformNode = {
id: string;
name: string;
subtitle: string;
models: Array<{
id: string;
name: string;
subtitle: string;
}>;
};
export function AccessRulesPanel(props: {
accessRules: GatewayAccessRule[];
baseModels: BaseModelCatalogItem[];
message: string;
platformModels: PlatformModel[];
platforms: IntegrationPlatform[];
state: LoadState;
userGroups: UserGroup[];
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
}) {
const [selectedGroupId, setSelectedGroupId] = useState(props.userGroups[0]?.id ?? '');
const [allowSearch, setAllowSearch] = useState('');
const [denySearch, setDenySearch] = useState('');
const [allowExpanded, setAllowExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
const [denyExpanded, setDenyExpanded] = useState<Set<string>>(() => new Set(props.platforms.map((item) => item.id)));
const [localError, setLocalError] = useState('');
const platformTree = useMemo(() => buildPlatformTree(props.platforms, props.platformModels), [props.platformModels, props.platforms]);
const selectedGroupRules = useMemo(
() => props.accessRules.filter((rule) => rule.subjectType === 'user_group' && rule.subjectId === selectedGroupId && rule.status === 'active'),
[props.accessRules, selectedGroupId],
);
const ruleByEffectAndResource = useMemo(() => buildRuleIndex(selectedGroupRules), [selectedGroupRules]);
const allowTree = useMemo(() => filterTree(platformTree, allowSearch), [allowSearch, platformTree]);
const denyTree = useMemo(() => filterTree(platformTree, denySearch), [denySearch, platformTree]);
useEffect(() => {
if (!selectedGroupId && props.userGroups[0]?.id) {
setSelectedGroupId(props.userGroups[0].id);
}
}, [props.userGroups, selectedGroupId]);
useEffect(() => {
setAllowExpanded((current) => mergeExpanded(current, props.platforms));
setDenyExpanded((current) => mergeExpanded(current, props.platforms));
}, [props.platforms]);
async function setPermission(effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) {
const resourceKey = makeResourceKey(resourceType, resourceId);
await applyPermissionBatch(effect, enabled ? [resourceKey] : [], enabled ? [] : [resourceKey]);
}
async function setPlatformPermission(effect: Effect, platform: PlatformNode, enabled: boolean) {
const keys = [
makeResourceKey('platform', platform.id),
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
];
await batchApply(effect, keys, enabled);
}
async function selectAll(effect: Effect, nodes: PlatformNode[]) {
await batchApply(effect, visibleResourceKeys(nodes), true);
}
async function reverseVisible(effect: Effect, nodes: PlatformNode[]) {
const keys = visibleResourceKeys(nodes);
const upsertKeys: ResourceKey[] = [];
const deleteKeys: ResourceKey[] = [];
for (const key of keys) {
const checked = Boolean(ruleByEffectAndResource.get(`${effect}:${key}`));
if (checked) deleteKeys.push(key);
else upsertKeys.push(key);
}
await applyPermissionBatch(effect, upsertKeys, deleteKeys);
}
async function clearEffect(effect: Effect) {
const keys = selectedGroupRules
.filter((rule) => rule.effect === effect)
.map(resourceKeyFromRule)
.filter((key): key is ResourceKey => Boolean(key));
await applyPermissionBatch(effect, [], keys, '访问权限清空失败');
}
async function batchApply(effect: Effect, resourceKeys: ResourceKey[], enabled: boolean) {
await applyPermissionBatch(effect, enabled ? resourceKeys : [], enabled ? [] : resourceKeys);
}
async function applyPermissionBatch(
effect: Effect,
upsertKeys: ResourceKey[],
deleteKeys: ResourceKey[],
errorMessage = '访问权限更新失败',
) {
if (!selectedGroupId) return;
const upsertResources = dedupeResourceKeys(upsertKeys).map((key) => createPermissionResource(effect, key));
const deleteResources = dedupeResourceKeys(deleteKeys).map(resourceRequestFromKey);
if (upsertResources.length === 0 && deleteResources.length === 0) return;
setLocalError('');
try {
await props.onBatchAccessRules({
subjectType: 'user_group',
subjectId: selectedGroupId,
effect,
upsertResources,
deleteResources,
});
} catch (err) {
setLocalError(err instanceof Error ? err.message : errorMessage);
}
}
const allowSummary = countEffectRules(selectedGroupRules, 'allow');
const denySummary = countEffectRules(selectedGroupRules, 'deny');
return (
<div className="pageStack">
<Card>
<CardHeader>
<div>
<CardTitle>访</CardTitle>
<p className="mutedText">使使/</p>
</div>
<Badge variant="secondary">{props.accessRules.length} </Badge>
</CardHeader>
<CardContent>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
<div className="accessGroupToolbar">
<Label>
<Select size="sm" value={selectedGroupId} onChange={(event) => setSelectedGroupId(event.target.value)}>
{props.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}
</Select>
</Label>
<div className="accessGroupHint">
<ShieldCheck size={15} />
<span></span>
</div>
</div>
</CardContent>
</Card>
{selectedGroupId ? (
<section className="accessPermissionGrid">
<PermissionTreePanel
emptyText="暂无可维护的平台模型"
effect="allow"
expanded={allowExpanded}
rules={ruleByEffectAndResource}
search={allowSearch}
state={props.state}
summary={allowSummary}
title="专属使用(平台/模型)"
tree={allowTree}
onClear={() => void clearEffect('allow')}
onExpandAll={() => setAllowExpanded(new Set(platformTree.map((item) => item.id)))}
onFoldAll={() => setAllowExpanded(new Set())}
onReverse={() => void reverseVisible('allow', allowTree)}
onSearchChange={setAllowSearch}
onSelectAll={() => void selectAll('allow', allowTree)}
onToggleExpanded={(platformId) => setAllowExpanded(toggleSet(allowExpanded, platformId))}
onTogglePlatformPermission={setPlatformPermission}
onTogglePermission={setPermission}
/>
<PermissionTreePanel
emptyText="暂无可维护的平台模型"
effect="deny"
expanded={denyExpanded}
rules={ruleByEffectAndResource}
search={denySearch}
state={props.state}
summary={denySummary}
title="不允许使用(平台/模型)"
tree={denyTree}
onClear={() => void clearEffect('deny')}
onExpandAll={() => setDenyExpanded(new Set(platformTree.map((item) => item.id)))}
onFoldAll={() => setDenyExpanded(new Set())}
onReverse={() => void reverseVisible('deny', denyTree)}
onSearchChange={setDenySearch}
onSelectAll={() => void selectAll('deny', denyTree)}
onToggleExpanded={(platformId) => setDenyExpanded(toggleSet(denyExpanded, platformId))}
onTogglePlatformPermission={setPlatformPermission}
onTogglePermission={setPermission}
/>
</section>
) : (
<div className="emptyState">
<strong></strong>
<span></span>
</div>
)}
</div>
);
}
function PermissionTreePanel(props: {
effect: Effect;
emptyText: string;
expanded: Set<string>;
rules: Map<string, GatewayAccessRule>;
search: string;
state: LoadState;
summary: { platforms: number; models: number };
title: string;
tree: PlatformNode[];
onClear: () => void;
onExpandAll: () => void;
onFoldAll: () => void;
onReverse: () => void;
onSearchChange: (value: string) => void;
onSelectAll: () => void;
onToggleExpanded: (platformId: string) => void;
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
}) {
return (
<Card className="accessPermissionPanel">
<CardHeader>
<div>
<CardTitle>{props.title}</CardTitle>
<p className="mutedText">{props.summary.platforms} / {props.summary.models} </p>
</div>
</CardHeader>
<CardContent>
<div className="accessTreeToolbar">
<Input size="sm" value={props.search} placeholder="筛选平台/模型" onChange={(event) => props.onSearchChange(event.target.value)} />
<Button type="button" variant="outline" size="sm" onClick={props.onExpandAll}></Button>
<Button type="button" variant="outline" size="sm" onClick={props.onFoldAll}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onSelectAll}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onReverse}></Button>
<Button type="button" variant="outline" size="sm" disabled={props.state === 'loading'} onClick={props.onClear}></Button>
</div>
<div className="accessTreeBox">
{props.tree.length ? props.tree.map((platform) => (
<PlatformPermissionNode
effect={props.effect}
expanded={props.expanded.has(platform.id)}
key={platform.id}
platform={platform}
rules={props.rules}
onToggleExpanded={props.onToggleExpanded}
onTogglePlatformPermission={props.onTogglePlatformPermission}
onTogglePermission={props.onTogglePermission}
/>
)) : <span className="accessTreeEmpty">{props.emptyText}</span>}
</div>
</CardContent>
</Card>
);
}
function PlatformPermissionNode(props: {
effect: Effect;
expanded: boolean;
platform: PlatformNode;
rules: Map<string, GatewayAccessRule>;
onToggleExpanded: (platformId: string) => void;
onTogglePlatformPermission: (effect: Effect, platform: PlatformNode, enabled: boolean) => void;
onTogglePermission: (effect: Effect, resourceType: ResourceType, resourceId: string, enabled: boolean) => void;
}) {
const platformRuleKey = `${props.effect}:${makeResourceKey('platform', props.platform.id)}`;
const checkedModels = props.platform.models.filter((model) => props.rules.has(`${props.effect}:${makeResourceKey('platform_model', model.id)}`)).length;
const platformChecked = props.rules.has(platformRuleKey);
const platformState = platformChecked ? true : checkedModels > 0 ? 'indeterminate' : false;
return (
<div className="accessTreeNode">
<div className="accessTreeRow">
<button type="button" className="accessTreeExpand" onClick={() => props.onToggleExpanded(props.platform.id)}>
{props.expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Checkbox
checked={platformState}
onCheckedChange={(checked) => props.onTogglePlatformPermission(props.effect, props.platform, checked === true)}
/>
<span title={props.platform.subtitle || props.platform.name}>{props.platform.name}</span>
{checkedModels > 0 && <Badge variant="secondary">{checkedModels}</Badge>}
</div>
{props.expanded && props.platform.models.length > 0 && (
<div className="accessTreeChildren">
{props.platform.models.map((model) => {
const modelKey = `${props.effect}:${makeResourceKey('platform_model', model.id)}`;
return (
<label className="accessTreeRow accessTreeModel" key={model.id}>
<span />
<Checkbox
checked={props.rules.has(modelKey)}
onCheckedChange={(checked) => props.onTogglePermission(props.effect, 'platform_model', model.id, checked === true)}
/>
<span title={model.subtitle || model.name}>{model.name}</span>
</label>
);
})}
</div>
)}
</div>
);
}
function buildPlatformTree(platforms: IntegrationPlatform[], platformModels: PlatformModel[]): PlatformNode[] {
const modelsByPlatform = new Map<string, PlatformModel[]>();
for (const model of platformModels) {
const current = modelsByPlatform.get(model.platformId) ?? [];
current.push(model);
modelsByPlatform.set(model.platformId, current);
}
return platforms.map((platform) => ({
id: platform.id,
name: platform.internalName || platform.name,
subtitle: `${platform.provider} / ${platform.platformKey}`,
models: (modelsByPlatform.get(platform.id) ?? [])
.slice()
.sort((a, b) => modelLabel(a).localeCompare(modelLabel(b)))
.map((model) => ({
id: model.id,
name: modelLabel(model),
subtitle: `${model.modelType} / ${model.modelName}`,
})),
})).sort((a, b) => a.name.localeCompare(b.name));
}
function filterTree(tree: PlatformNode[], keyword: string): PlatformNode[] {
const normalized = keyword.trim().toLowerCase();
if (!normalized) return tree;
return tree.flatMap((platform) => {
const platformMatched = `${platform.name} ${platform.subtitle}`.toLowerCase().includes(normalized);
const models = platformMatched
? platform.models
: platform.models.filter((model) => `${model.name} ${model.subtitle}`.toLowerCase().includes(normalized));
return platformMatched || models.length ? [{ ...platform, models }] : [];
});
}
function buildRuleIndex(rules: GatewayAccessRule[]) {
const index = new Map<string, GatewayAccessRule>();
for (const rule of rules) {
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') continue;
if (rule.effect !== 'allow' && rule.effect !== 'deny') continue;
index.set(`${rule.effect}:${makeResourceKey(rule.resourceType, rule.resourceId)}`, rule);
}
return index;
}
function countEffectRules(rules: GatewayAccessRule[], effect: Effect) {
return rules.reduce((summary, rule) => {
if (rule.effect !== effect) return summary;
if (rule.resourceType === 'platform') summary.platforms += 1;
if (rule.resourceType === 'platform_model') summary.models += 1;
return summary;
}, { platforms: 0, models: 0 });
}
function visibleResourceKeys(nodes: PlatformNode[]): ResourceKey[] {
return nodes.flatMap((platform) => [
makeResourceKey('platform', platform.id),
...platform.models.map((model) => makeResourceKey('platform_model', model.id)),
]);
}
function createPermissionResource(
effect: Effect,
resourceKey: ResourceKey,
): GatewayAccessRuleResourceRequest {
const [resourceType, resourceId] = splitResourceKey(resourceKey);
return {
resourceType,
resourceId,
priority: effect === 'deny' ? 10 : 100,
minPermissionLevel: 0,
conditions: {},
metadata: { uiMode: 'user_group_permission_tree' },
status: 'active',
};
}
function resourceRequestFromKey(resourceKey: ResourceKey): GatewayAccessRuleResourceRequest {
const [resourceType, resourceId] = splitResourceKey(resourceKey);
return { resourceType, resourceId };
}
function resourceKeyFromRule(rule: GatewayAccessRule): ResourceKey | undefined {
if (rule.resourceType !== 'platform' && rule.resourceType !== 'platform_model') return undefined;
return makeResourceKey(rule.resourceType, rule.resourceId);
}
function dedupeResourceKeys(keys: ResourceKey[]) {
return Array.from(new Set(keys.filter(Boolean)));
}
function mergeExpanded(current: Set<string>, platforms: IntegrationPlatform[]) {
if (current.size > 0) return current;
return new Set(platforms.map((item) => item.id));
}
function toggleSet(set: Set<string>, value: string) {
const next = new Set(set);
if (next.has(value)) next.delete(value);
else next.add(value);
return next;
}
function makeResourceKey(resourceType: ResourceType, resourceId: string): ResourceKey {
return `${resourceType}:${resourceId}`;
}
function splitResourceKey(key: ResourceKey): [ResourceType, string] {
const [resourceType, ...rest] = key.split(':');
return [resourceType as ResourceType, rest.join(':')];
}
function modelLabel(model: PlatformModel) {
return model.displayName || model.modelAlias || model.modelName;
}
@@ -0,0 +1,843 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import { Brain, Image, ListChecks, Plus, Trash2, Video } from 'lucide-react';
import { Button, Checkbox, Input, Select } from '../../components/ui';
import {
addCapabilityType,
defaultCapabilityConfig,
modelTypeGroup,
modelTypeLabel,
removeCapabilityType,
updateCapabilityConfig,
type CapabilityEditorState,
} from './base-model-capabilities';
type FieldType =
| 'boolean'
| 'number'
| 'text'
| 'list'
| 'numberList'
| 'range'
| 'ratioRange'
| 'scopedList'
| 'scopedNumberList'
| 'scopedRange'
| 'scopedNumber';
type FieldScope = 'mode' | 'resolution' | 'mixed';
type FieldDefinition = {
key: string;
label: string;
hint?: string;
placeholder?: string;
type: FieldType;
scope?: FieldScope;
};
type ValueOption = { label: string; value: string };
const textFields: FieldDefinition[] = [
{ key: 'supportTool', label: '工具调用', hint: 'function calling / tools', type: 'boolean' },
{ key: 'supportStructuredOutput', label: '结构化输出', hint: 'JSON Schema 等输出', type: 'boolean' },
{ key: 'supportThinking', label: '思考能力', hint: '支持 thinking 参数', type: 'boolean' },
{ key: 'supportThinkingModeSwitch', label: '思考开关', hint: '可按请求切换', type: 'boolean' },
{ key: 'supportWebSearch', label: '联网搜索', type: 'boolean' },
{ key: 'max_context_tokens', label: '上下文 Token', placeholder: '128000', type: 'number' },
{ key: 'max_input_tokens', label: '最大输入 Token', placeholder: '64000', type: 'number' },
{ key: 'max_output_tokens', label: '最大输出 Token', placeholder: '8192', type: 'number' },
{ key: 'max_thinking_tokens', label: '最大思考 Token', placeholder: '32768', type: 'number' },
{ key: 'thinkingEffortLevels', label: '思考强度', placeholder: 'minimal, low, medium, high', type: 'list' },
];
const embeddingFields: FieldDefinition[] = [
{ key: 'dimensions', label: '向量维度', placeholder: '1024, 768, 512', type: 'numberList' },
];
const imageFields: FieldDefinition[] = [
{ key: 'support_base64_input', label: 'Base64 输入', type: 'boolean' },
{ key: 'support_url_input', label: 'URL 输入', type: 'boolean' },
{ key: 'input_multiple_images', label: '多图输入', type: 'boolean' },
{ key: 'input_max_images_count', label: '最多输入图片', placeholder: '10', type: 'number' },
{ key: 'output_multiple_images', label: '多图输出', type: 'boolean' },
{ key: 'output_max_images_count', label: '最多输出图片', placeholder: '4', type: 'number' },
{ key: 'output_resolutions', label: '输出分辨率', placeholder: '1K, 2K, 4K', type: 'list' },
{ key: 'output_max_size', label: '最大输出尺寸', placeholder: '16777216', type: 'number' },
{ key: 'aspect_ratio_allowed', label: '支持宽高比', placeholder: '16:9, 1:1, 9:16', type: 'list' },
{ key: 'aspect_ratio_range', label: '宽高比范围', hint: '按宽/高数值限制,如 0.125 - 8', placeholder: '0.125 - 8', type: 'ratioRange' },
];
const videoFields: FieldDefinition[] = [
{ key: 'support_base64_input', label: 'Base64 输入', type: 'boolean' },
{ key: 'support_url_input', label: 'URL 输入', type: 'boolean' },
{ key: 'output_resolutions', label: '输出分辨率', hint: '可直接配置,也可按首帧/首尾帧等模式配置', placeholder: '720p, 1080p', type: 'scopedList', scope: 'mode' },
{ key: 'aspect_ratio_allowed', label: '支持宽高比', hint: '支持按分辨率配置可选宽高比', placeholder: '16:9, 9:16, 1:1', type: 'scopedList', scope: 'resolution' },
{ key: 'aspect_ratio_range', label: '宽高比范围', hint: '按宽/高数值限制,如 0.125 - 8', placeholder: '0.125 - 8', type: 'ratioRange' },
{ key: 'duration_range', label: '时长范围', hint: '支持按分辨率或模式配置不同范围', placeholder: '5 - 10', type: 'scopedRange', scope: 'mixed' },
{ key: 'duration_options', label: '可选时长', hint: '与时长范围二选一;只允许这些秒数', placeholder: '5, 10', type: 'scopedNumberList', scope: 'mixed' },
{ key: 'duration_step', label: '时长间隔', hint: '自定义时长按该秒数递增或取整', placeholder: '1', type: 'scopedNumber', scope: 'mixed' },
{ key: 'input_audio', label: '音频输入', type: 'boolean' },
{ key: 'output_audio', label: '输出音频', type: 'boolean' },
{ key: 'output_bgm', label: '背景音乐', type: 'boolean' },
{ key: 'input_first_frame', label: '首帧输入', type: 'boolean' },
{ key: 'input_last_frame', label: '尾帧输入', type: 'boolean' },
{ key: 'input_first_last_frame', label: '首尾帧模式', type: 'boolean' },
{ key: 'input_reference_generate_single', label: '单参考图', type: 'boolean' },
{ key: 'input_reference_generate_multiple', label: '多参考图', type: 'boolean' },
{ key: 'input_smart_multi_frame', label: '智能多帧', type: 'boolean' },
{ key: 'smart_multi_frame_range', label: '智能多帧数量', placeholder: '2 - 9', type: 'range' },
{ key: 'smart_multi_frame_mode', label: '智能多帧模式', placeholder: 'native 或 stitch', type: 'text' },
{ key: 'smart_multi_frame_duration_range', label: '智能多帧每段时长', placeholder: '2 - 7', type: 'range' },
{ key: 'support_video_effect_template', label: '视频特效模板', type: 'boolean' },
{ key: 'supported_modes', label: '支持模式', placeholder: 'text_to_video, image_reference', type: 'list' },
{ key: 'max_videos', label: '最多视频', placeholder: '1', type: 'number' },
{ key: 'max_images', label: '最多图片', placeholder: '4', type: 'number' },
{ key: 'max_audios', label: '最多音频', placeholder: '1', type: 'number' },
{ key: 'max_elements', label: '最多主体', placeholder: '4', type: 'number' },
{ key: 'max_images_and_elements', label: '图片主体合计', placeholder: '4', type: 'number' },
{ key: 'support_instruction_edit', label: '指令编辑', type: 'boolean' },
];
const model3dFields: FieldDefinition[] = [
{ key: 'support_texture', label: '纹理生成', type: 'boolean' },
{ key: 'support_part_generation', label: '分部生成', type: 'boolean' },
{ key: 'support_image_autofix', label: '图片自动优化', type: 'boolean' },
{ key: 'support_quad', label: '四边面输出', type: 'boolean' },
{ key: 'support_smart_low_poly', label: '智能低模', type: 'boolean' },
{ key: 'geometry_quality_options', label: '几何质量', placeholder: 'standard, detailed', type: 'list' },
{ key: 'max_face_limit', label: '三角面上限', placeholder: '20000', type: 'number' },
{ key: 'max_face_limit_quad', label: '四边面上限', placeholder: '10000', type: 'number' },
];
export function BaseModelCapabilityEditor(props: {
modelType: string;
typeOptions: string[];
value: CapabilityEditorState;
onChange: (value: CapabilityEditorState) => void;
}) {
const enabledTypes = props.value.types;
const [activeType, setActiveType] = useState(enabledTypes[0] ?? props.modelType);
const [pendingType, setPendingType] = useState('');
const availableTypes = useMemo(
() => Array.from(new Set([props.modelType, ...props.typeOptions].filter(Boolean))),
[props.modelType, props.typeOptions],
);
const addableTypes = availableTypes.filter((type) => !enabledTypes.includes(type));
const activeConfig = props.value.typeConfigs[activeType] ?? defaultCapabilityConfig(activeType);
const fieldGroups = capabilityFieldGroups(activeType);
const capabilitySummary = useMemo(
() => summarizeEnabledCapabilities(enabledTypes, props.value.typeConfigs),
[enabledTypes, props.value.typeConfigs],
);
useEffect(() => {
if (!enabledTypes.includes(activeType)) setActiveType(enabledTypes[0] ?? props.modelType);
}, [activeType, enabledTypes, props.modelType]);
function addType() {
const nextType = pendingType || addableTypes[0];
if (!nextType) return;
props.onChange(addCapabilityType(props.value, nextType));
setActiveType(nextType);
setPendingType('');
}
function removeType(type: string) {
const next = removeCapabilityType(props.value, type);
props.onChange(next);
setActiveType(next.types[0] ?? props.modelType);
}
function patchConfig(key: string, value: unknown) {
const nextConfig = { ...activeConfig };
if (value === undefined) delete nextConfig[key];
else nextConfig[key] = value;
const exclusiveKey = exclusiveCapabilityFields[key];
if (exclusiveKey && hasMeaningfulValue(value)) delete nextConfig[exclusiveKey];
props.onChange(updateCapabilityConfig(props.value, activeType, sanitizeCapabilityConfig(nextConfig)));
}
return (
<section className="baseCapabilityEditor spanTwo bg-card text-foreground">
<header>
<div>
<strong></strong>
<span></span>
</div>
</header>
<div className="capabilityWorkbench">
<aside className="capabilitySidebar">
<div className="capabilitySidebarTitle">
<ListChecks size={15} />
<strong></strong>
</div>
<div className="capabilityList">
{enabledTypes.map((type) => (
<button className="capabilityListItem" data-active={type === activeType} key={type} type="button" onClick={() => setActiveType(type)}>
<span>
<strong>{modelTypeLabel(type)}</strong>
<small>{modelTypeGroup(type)} / {type}</small>
</span>
{enabledTypes.length > 1 && (
<Trash2
size={14}
onClick={(event) => {
event.stopPropagation();
removeType(type);
}}
/>
)}
</button>
))}
</div>
<div className="capabilityAddBox">
<Select size="sm" value={pendingType} onChange={(event) => setPendingType(event.target.value)}>
<option value="">{addableTypes.length ? '选择要添加的能力' : '没有可添加能力'}</option>
{addableTypes.map((type) => <option key={type} value={type}>{modelTypeLabel(type)} / {type}</option>)}
</Select>
<Button size="sm" type="button" variant="outline" disabled={!addableTypes.length} onClick={addType}>
<Plus size={14} />
</Button>
</div>
<div className="capabilitySummary">
<strong></strong>
<p>{capabilitySummary}</p>
</div>
</aside>
<div className="capabilityFormPanel">
{fieldGroups.length ? (
fieldGroups.map((group) => (
<CapabilityFormGroup key={group.title} icon={group.icon} title={group.title}>
{group.fields.map((field) => (
<CapabilityFormRow
key={field.key}
config={activeConfig}
defaultConfig={defaultCapabilityConfig(activeType)}
field={field}
onChange={(value) => patchConfig(field.key, value)}
/>
))}
</CapabilityFormGroup>
))
) : (
<div className="capabilityPreserveNote">
<strong></strong>
<span></span>
</div>
)}
<CapabilityFormGroup icon={<Brain size={15} />} title="保留字段">
<div className="capabilityPreserveNote">
<strong>{preservedConfigCount(activeConfig, fieldGroups)} </strong>
<span> {activeType} </span>
</div>
</CapabilityFormGroup>
</div>
</div>
</section>
);
}
function CapabilityFormGroup(props: { children: ReactNode; icon: ReactNode; title: string }) {
return (
<section className="capabilityFormGroup">
<header>
{props.icon}
<strong>{props.title}</strong>
</header>
<div className="capabilityFormRows">{props.children}</div>
</section>
);
}
function CapabilityFormRow(props: {
config: Record<string, unknown>;
defaultConfig: Record<string, unknown>;
field: FieldDefinition;
onChange: (value: unknown) => void;
}) {
const value = props.config[props.field.key];
return (
<div className="capabilityFormRow">
<div>
<strong>{props.field.label}</strong>
{props.field.hint && <small title={props.field.hint}>{props.field.hint}</small>}
</div>
<FieldControl
config={props.config}
defaultValue={props.defaultConfig[props.field.key]}
field={props.field}
value={value}
onChange={props.onChange}
/>
</div>
);
}
function FieldControl(props: {
config: Record<string, unknown>;
defaultValue: unknown;
field: FieldDefinition;
value: unknown;
onChange: (value: unknown) => void;
}) {
if (props.field.type === 'boolean') {
return <BooleanFieldControl defaultText={booleanDefaultText(props.field, props.defaultValue)} value={props.value} onChange={props.onChange} />;
}
if (props.field.type === 'range') {
const range = Array.isArray(props.value) ? props.value : [];
return (
<div className="capabilityRange">
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[0])} placeholder="最小" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[1])} placeholder="最大" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
</div>
);
}
if (props.field.type === 'ratioRange') {
const range = Array.isArray(props.value) ? props.value : [];
return (
<div className="capabilityRange">
<Input size="sm" min="0" step="0.001" type="number" value={stringValue(range[0])} placeholder="最小宽/高" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
<Input size="sm" min="0" step="0.001" type="number" value={stringValue(range[1])} placeholder="最大宽/高" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
</div>
);
}
if (props.field.type === 'number') {
return <Input size="sm" min="0" step="1" type="number" value={stringValue(props.value)} placeholder={props.field.placeholder} onChange={(event) => props.onChange(numberValue(event.target.value))} />;
}
if (isScopedField(props.field)) {
return <ScopedFieldControl field={props.field} value={props.value} config={props.config} onChange={props.onChange} />;
}
if (props.field.type === 'text') {
return <Input size="sm" value={stringValue(props.value)} placeholder={props.field.placeholder} onChange={(event) => props.onChange(event.target.value)} />;
}
return <MultiValueControl config={props.config} field={props.field} value={props.value} onChange={props.onChange} />;
}
function BooleanFieldControl(props: {
defaultText: string;
onChange: (value: unknown) => void;
value: unknown;
}) {
return (
<Select
className="capabilityBooleanSelect"
size="sm"
value={booleanSelectValue(props.value)}
onChange={(event) => props.onChange(booleanValueFromSelect(event.target.value))}
>
<option value="unset">{props.defaultText}</option>
<option value="true"></option>
<option value="false"></option>
</Select>
);
}
function ScopedFieldControl(props: {
config: Record<string, unknown>;
field: FieldDefinition;
value: unknown;
onChange: (value: unknown) => void;
}) {
const grouped = isPlainRecord(props.value);
const directValue = grouped ? defaultDirectValue(props.field) : props.value;
const scopeOptions = scopeOptionsForField(props.field, props.config, props.value);
const entries = grouped ? Object.entries(props.value as Record<string, unknown>) : [];
const canGroup = scopeOptions.length > 0 || entries.length > 0;
function switchMode(nextGrouped: boolean) {
if (nextGrouped === grouped) return;
if (!nextGrouped) {
props.onChange(entries[0]?.[1] ?? defaultDirectValue(props.field));
return;
}
const firstKey = firstAvailableScopeKey(scopeOptions, []);
props.onChange({ [firstKey]: normalizeScopedValue(props.field, props.value) });
}
function setEntryKey(oldKey: string, nextKey: string) {
if (!nextKey || !grouped) return;
const next = { ...(props.value as Record<string, unknown>) };
const current = next[oldKey];
delete next[oldKey];
next[nextKey] = current;
props.onChange(next);
}
function setEntryValue(key: string, nextValue: unknown) {
props.onChange({ ...(props.value as Record<string, unknown>), [key]: nextValue });
}
function removeEntry(key: string) {
const next = { ...(props.value as Record<string, unknown>) };
delete next[key];
props.onChange(Object.keys(next).length ? next : defaultDirectValue(props.field));
}
function addEntry() {
const used = entries.map(([key]) => key);
const nextKey = firstAvailableScopeKey(scopeOptions, used);
const current = grouped ? (props.value as Record<string, unknown>) : {};
props.onChange({ ...current, [nextKey]: defaultDirectValue(props.field) });
}
return (
<div className="capabilityScopedControl">
<div className="capabilitySegmented" role="tablist" aria-label={`${props.field.label}配置方式`}>
<button type="button" data-active={!grouped} onClick={() => switchMode(false)}></button>
<button type="button" disabled={!canGroup} data-active={grouped} onClick={() => switchMode(true)}></button>
</div>
{!grouped ? (
<ScopedValueInput config={props.config} field={props.field} value={directValue} placeholder={props.field.placeholder} onChange={props.onChange} />
) : (
<div className="capabilityScopedRows">
{entries.map(([key, value]) => (
<div className="capabilityScopedRow" key={key}>
<Select size="sm" value={key} onChange={(event) => setEntryKey(key, event.target.value)}>
{ensureScopeOption(scopeOptions, key).map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</Select>
<ScopedValueInput config={props.config} field={props.field} value={value} placeholder={props.field.placeholder} onChange={(nextValue) => setEntryValue(key, nextValue)} />
<Button type="button" size="icon" variant="ghost" aria-label="删除条件" onClick={() => removeEntry(key)}>
<Trash2 size={14} />
</Button>
</div>
))}
<Button type="button" size="sm" variant="outline" onClick={addEntry}>
<Plus size={14} />
</Button>
</div>
)}
</div>
);
}
function ScopedValueInput(props: {
config: Record<string, unknown>;
field: FieldDefinition;
onChange: (value: unknown) => void;
placeholder?: string;
value: unknown;
}) {
if (props.field.type === 'scopedRange') {
const range = Array.isArray(props.value) ? props.value : [];
return (
<div className="capabilityRange">
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[0])} placeholder="最小" onChange={(event) => props.onChange([numberValue(event.target.value), numberValue(range[1])])} />
<Input size="sm" min="0" step="1" type="number" value={stringValue(range[1])} placeholder="最大" onChange={(event) => props.onChange([numberValue(range[0]), numberValue(event.target.value)])} />
</div>
);
}
if (props.field.type === 'scopedNumber') {
return <Input size="sm" min="0" step="1" type="number" value={stringValue(props.value)} placeholder={props.placeholder} onChange={(event) => props.onChange(numberValue(event.target.value))} />;
}
return <MultiValueControl config={props.config} field={props.field} value={props.value} onChange={props.onChange} />;
}
function MultiValueControl(props: {
config: Record<string, unknown>;
field: FieldDefinition;
onChange: (value: unknown) => void;
value: unknown;
}) {
const [customValue, setCustomValue] = useState('');
const selected = valueArray(props.value);
const options = multiOptionsForField(props.field, props.config, props.value);
const selectedSet = new Set(selected);
function emit(nextValues: string[]) {
const values = uniqueStrings(nextValues);
props.onChange(isNumberListField(props.field) ? values.map(Number).filter(Number.isFinite) : values);
}
function toggle(value: string) {
emit(selectedSet.has(value) ? selected.filter((item) => item !== value) : [...selected, value]);
}
function addCustom() {
const values = customValue.split(/[,]/).map((item) => item.trim()).filter(Boolean);
if (!values.length) return;
emit([...selected, ...values]);
setCustomValue('');
}
return (
<div className="capabilityMultiValue">
<div className="capabilityMultiOptions">
{options.map((option) => (
<label key={option.value} className="capabilityCheckboxOption">
<Checkbox
checked={selectedSet.has(option.value)}
onCheckedChange={() => toggle(option.value)}
/>
{option.label}
</label>
))}
</div>
<div className="capabilityCustomValue">
<Input
size="sm"
value={customValue}
placeholder={props.field.placeholder ? `自定义:${props.field.placeholder}` : '自定义值'}
onChange={(event) => setCustomValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
addCustom();
}
}}
/>
<Button className="capabilityAddValueButton" type="button" size="sm" variant="outline" onClick={addCustom}></Button>
</div>
</div>
);
}
type ScopeOption = { label: string; requires?: string; value: string };
const modeScopeOptions: ScopeOption[] = [
{ value: 'input_first_frame', label: '首帧模式 (input_first_frame)', requires: 'input_first_frame' },
{ value: 'input_first_last_frame', label: '首尾帧模式 (input_first_last_frame)', requires: 'input_first_last_frame' },
{ value: 'input_last_frame', label: '尾帧模式 (input_last_frame)', requires: 'input_last_frame' },
{ value: 'input_smart_multi_frame', label: '智能多帧 (input_smart_multi_frame)', requires: 'input_smart_multi_frame' },
];
const videoResolutionOptions = ['480p', '720p', '1080p', '1440p', '2160p'];
const imageResolutionOptions = ['1K', '2K', '3K', '4K', '8K'];
const videoAspectRatioOptions = ['adaptive', '16:9', '9:16', '1:1', '4:3', '3:4'];
const imageAspectRatioOptions = [
'adaptive',
'1:1',
'16:9',
'9:16',
'4:3',
'3:4',
'3:2',
'2:3',
'5:4',
'4:5',
'5:3',
'3:5',
'21:9',
'9:21',
'2:1',
'1:2',
'4:1',
'1:4',
'8:1',
'1:8',
'7:4',
'4:7',
];
const thinkingEffortOptions = ['minimal', 'low', 'medium', 'high'];
const omniVideoModeOptions = ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'];
const durationOptionValues = ['1', '2', '3', '4', '5', '6', '8', '10', '15', '20', '25', '30'];
const exclusiveCapabilityFields: Record<string, string> = {
duration_range: 'duration_options',
duration_options: 'duration_range',
};
function isScopedField(field: FieldDefinition) {
return field.type === 'scopedList' || field.type === 'scopedNumberList' || field.type === 'scopedRange' || field.type === 'scopedNumber';
}
function scopeOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ScopeOption[] {
const options: ScopeOption[] = [];
if (field.scope === 'mode' || field.scope === 'mixed') {
options.push(...modeScopeOptions.filter((option) => !option.requires || config[option.requires] === true));
flattenStringValues(config.supported_modes).forEach((mode) => options.push({ value: mode, label: `${friendlyScopeLabel(mode)} (${mode})` }));
}
if (field.scope === 'resolution' || field.scope === 'mixed') {
[...flattenStringValues(config.output_resolutions), ...videoResolutionOptions].forEach((resolution) => {
options.push({ value: resolution, label: `${friendlyScopeLabel(resolution)} (${resolution})` });
});
}
if (isPlainRecord(value)) {
Object.keys(value).forEach((key) => options.push({ value: key, label: `${friendlyScopeLabel(key)} (${key})` }));
}
return dedupeScopeOptions(options);
}
function ensureScopeOption(options: ScopeOption[], value: string) {
if (!value || options.some((option) => option.value === value)) return options;
return [...options, { value, label: `${friendlyScopeLabel(value)} (${value})` }];
}
function firstAvailableScopeKey(options: ScopeOption[], used: string[]) {
return options.find((option) => !used.includes(option.value))?.value ?? `condition_${used.length + 1}`;
}
function sanitizeCapabilityConfig(config: Record<string, unknown>) {
const supportedModeKeys = new Set([
...modeScopeOptions.filter((option) => !option.requires || config[option.requires] === true).map((option) => option.value),
...flattenStringValues(config.supported_modes),
]);
const keepScopedModeKey = (key: string) => !isKnownModeScopeKey(key) || supportedModeKeys.has(key);
return Object.fromEntries(
Object.entries(config).map(([key, value]) => {
if (!isScopedConfigKey(key) || !isPlainRecord(value)) return [key, value];
const filtered = Object.fromEntries(Object.entries(value).filter(([scopeKey]) => keepScopedModeKey(scopeKey)));
if (Object.keys(filtered).length > 0) return [key, filtered];
return [key, defaultDirectValueForConfigKey(key)];
}),
);
}
function isScopedConfigKey(key: string) {
return key === 'output_resolutions' || key === 'aspect_ratio_allowed' || key === 'duration_range' || key === 'duration_options' || key === 'duration_step';
}
function isKnownModeScopeKey(key: string) {
return modeScopeOptions.some((option) => option.value === key) || ['text_to_video', 'image_reference', 'element_reference', 'first_last_frame', 'video_reference', 'video_edit', 'multi_shot'].includes(key);
}
function defaultDirectValueForConfigKey(key: string) {
if (key === 'duration_range') return [0, 0];
if (key === 'duration_step') return 0;
return [];
}
function multiOptionsForField(field: FieldDefinition, config: Record<string, unknown>, value: unknown): ValueOption[] {
let options: string[] = [];
if (field.key === 'output_resolutions') options = field.scope ? videoResolutionOptions : imageResolutionOptions;
if (field.key === 'aspect_ratio_allowed') options = field.scope ? videoAspectRatioOptions : imageAspectRatioOptions;
if (field.key === 'supported_modes') options = omniVideoModeOptions;
if (field.key === 'thinkingEffortLevels') options = thinkingEffortOptions;
if (field.key === 'duration_options') options = durationOptionValues;
if (field.key === 'dimensions') options = ['3072', '1536', '1024', '768', '512', '256'];
if (field.key === 'geometry_quality_options') options = ['standard', 'detailed'];
options = orderedOptionValues(options, [...valueArray(value), ...flattenStringValues(config[field.key])], field);
return options.map((item) => ({ value: item, label: friendlyOptionLabel(field, item) }));
}
function orderedOptionValues(baseOptions: string[], extraOptions: string[], field: FieldDefinition) {
const base = uniqueStrings(baseOptions);
const baseSet = new Set(base);
const extras = uniqueStrings(extraOptions).filter((item) => !baseSet.has(item));
if (field.key === 'duration_options') {
return uniqueStrings([...base, ...extras]).sort((a, b) => Number(a) - Number(b));
}
const sortedExtras = isNumberListField(field)
? extras.sort((a, b) => Number(a) - Number(b))
: extras.sort((a, b) => a.localeCompare(b));
return [...base, ...sortedExtras];
}
function friendlyOptionLabel(field: FieldDefinition, value: string) {
if (field.key === 'duration_options') return `${value}`;
if (field.key === 'supported_modes') return friendlyScopeLabel(value);
return value;
}
function valueArray(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
if (value === undefined || value === null || value === '') return [];
return [String(value)];
}
function isNumberListField(field: FieldDefinition) {
return field.type === 'numberList' || field.type === 'scopedNumberList';
}
function friendlyScopeLabel(key: string) {
const labels: Record<string, string> = {
input_first_frame: '首帧模式',
input_first_last_frame: '首尾帧模式',
input_last_frame: '尾帧模式',
input_smart_multi_frame: '智能多帧',
text_to_video: '文生视频',
image_reference: '图片参考',
element_reference: '主体参考',
first_last_frame: '首尾帧',
video_reference: '视频参考',
video_edit: '视频编辑',
multi_shot: '多镜头',
};
return labels[key] ?? key;
}
function dedupeScopeOptions(options: ScopeOption[]) {
const seen = new Set<string>();
return options.filter((option) => {
if (!option.value || seen.has(option.value)) return false;
seen.add(option.value);
return true;
});
}
function flattenStringValues(value: unknown): string[] {
if (Array.isArray(value)) return value.map(String).filter(Boolean);
if (!isPlainRecord(value)) return [];
return Object.values(value).flatMap((item) => Array.isArray(item) ? item.map(String).filter(Boolean) : []);
}
function defaultDirectValue(field: FieldDefinition) {
if (field.type === 'scopedRange') return [0, 0];
if (field.type === 'scopedNumber') return 0;
return [];
}
function normalizeScopedValue(field: FieldDefinition, value: unknown) {
if (isPlainRecord(value)) return Object.values(value)[0] ?? defaultDirectValue(field);
if (field.type === 'scopedRange') return Array.isArray(value) ? value : defaultDirectValue(field);
if (field.type === 'scopedNumber') return typeof value === 'number' || typeof value === 'string' ? numberValue(value) : 0;
return Array.isArray(value) ? value : defaultDirectValue(field);
}
function hasMeaningfulValue(value: unknown): boolean {
if (value === undefined || value === null || value === '') return false;
if (Array.isArray(value)) return value.length > 0;
if (isPlainRecord(value)) return Object.keys(value).length > 0;
return true;
}
function capabilityFieldGroups(type: string): Array<{ title: string; icon: ReactNode; fields: FieldDefinition[] }> {
if (isTextType(type)) return [{ title: '文本默认能力', icon: <Brain size={15} />, fields: textFields }];
if (type === 'text_embedding') return [{ title: '向量默认能力', icon: <Brain size={15} />, fields: embeddingFields }];
if (isImageType(type)) return [{ title: '图像默认能力', icon: <Image size={15} />, fields: imageFieldsFor(type) }];
if (isVideoType(type)) return [{ title: '视频默认能力', icon: <Video size={15} />, fields: videoFieldsFor(type) }];
if (isModel3dType(type)) return [{ title: '3D 默认能力', icon: <Brain size={15} />, fields: model3dFields }];
return [];
}
function imageFieldsFor(type: string) {
if (type === 'image_edit') return imageFields;
return imageFields.filter((field) => !field.key.startsWith('input_'));
}
function videoFieldsFor(type: string) {
if (type === 'video_generate') return videoFields.filter((field) => !field.key.startsWith('input_') && field.key !== 'supported_modes');
if (type === 'image_to_video') return videoFields.filter((field) => field.key !== 'supported_modes' && field.key !== 'max_audios');
if (type === 'video_edit') return videoFields.filter((field) => !field.key.includes('reference_generate') && field.key !== 'supported_modes');
return videoFields;
}
function preservedConfigCount(config: Record<string, unknown>, groups: Array<{ fields: FieldDefinition[] }>) {
const shown = new Set(groups.flatMap((group) => group.fields.map((field) => field.key)));
return Object.keys(config).filter((key) => !shown.has(key)).length;
}
function summarizeEnabledCapabilities(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
if (!types.length) return '暂无启用能力,添加能力后会在这里显示模型支持范围。';
const groups = uniqueStrings(types.map(modelTypeGroup));
const names = types.map(modelTypeLabel);
const nameText = names.length > 3 ? `${names.slice(0, 3).join('、')}${names.length} 项能力` : names.join('、');
const highlights = capabilitySummaryHighlights(types, typeConfigs);
const base = `已启用 ${types.length} 项能力,覆盖${groups.join('、')}。包含 ${nameText}`;
return highlights.length ? `${base}${highlights.join('')}` : `${base}`;
}
function capabilitySummaryHighlights(types: string[], typeConfigs: Record<string, Record<string, unknown>>) {
const highlights: string[] = [];
const textLimits = uniqueStrings(types.flatMap((type) => stringValue(typeConfigs[type]?.max_context_tokens ? typeConfigs[type].max_context_tokens : '').split(',').filter(Boolean)));
if (textLimits.length) highlights.push(`上下文 ${textLimits[0]} Token`);
const resolutions = uniqueStrings(types.flatMap((type) => flattenStringValues(typeConfigs[type]?.output_resolutions)));
if (resolutions.length) highlights.push(`输出分辨率 ${resolutions.slice(0, 4).join('/')}`);
const aspectRatioRanges = uniqueStrings(types.flatMap((type) => aspectRatioRangeSummaryValues(typeConfigs[type])));
if (aspectRatioRanges.length) highlights.push(`宽高比 ${aspectRatioRanges.slice(0, 2).join('/')}`);
const durations = uniqueStrings(types.flatMap((type) => durationSummaryValues(typeConfigs[type])));
if (durations.length) highlights.push(`时长 ${durations.slice(0, 3).join('/')}`);
const booleanLabels = types.flatMap((type) => enabledBooleanLabels(typeConfigs[type]));
if (booleanLabels.length) highlights.push(`支持${uniqueStrings(booleanLabels).slice(0, 3).join('、')}`);
return highlights.slice(0, 4);
}
function aspectRatioRangeSummaryValues(config?: Record<string, unknown>) {
const range = config?.aspect_ratio_range;
if (!Array.isArray(range) || range.length < 2) return [];
return [`${range[0]}-${range[1]}`];
}
function durationSummaryValues(config?: Record<string, unknown>) {
if (!config) return [];
const options = flattenStringValues(config.duration_options).map((item) => `${item}`);
if (options.length) return options;
const range = config.duration_range;
if (Array.isArray(range) && range.length >= 2) return [`${range[0]}-${range[1]}`];
if (!isPlainRecord(range)) return [];
return Object.values(range).flatMap((item) => Array.isArray(item) && item.length >= 2 ? [`${item[0]}-${item[1]}`] : []);
}
function enabledBooleanLabels(config?: Record<string, unknown>) {
if (!config) return [];
const labels: Record<string, string> = {
supportTool: '工具调用',
supportStructuredOutput: '结构化输出',
supportThinking: '思考能力',
supportWebSearch: '联网搜索',
support_base64_input: 'Base64 输入',
support_url_input: 'URL 输入',
input_multiple_images: '多图输入',
output_multiple_images: '多图输出',
input_audio: '音频输入',
output_audio: '输出音频',
output_bgm: '背景音乐',
input_first_frame: '首帧输入',
input_first_last_frame: '首尾帧模式',
input_reference_generate_single: '单参考图',
input_reference_generate_multiple: '多参考图',
support_video_effect_template: '视频特效模板',
};
return Object.entries(labels).filter(([key]) => config[key] === true).map(([, label]) => label);
}
function isTextType(type: string) {
return ['text_generate', 'image_analysis', 'video_understanding', 'audio_understanding', 'omni', 'tools_call'].includes(type);
}
function isImageType(type: string) {
return type === 'image_generate' || type === 'image_edit';
}
function isVideoType(type: string) {
return ['video_generate', 'image_to_video', 'video_edit', 'omni_video'].includes(type);
}
function isModel3dType(type: string) {
return ['text_to_model', 'image_to_model', 'multiview_to_model', 'mesh_edit'].includes(type);
}
function numberValue(value: unknown) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function booleanSelectValue(value: unknown) {
if (value === true) return 'true';
if (value === false) return 'false';
return 'unset';
}
function booleanDefaultText(field: FieldDefinition, defaultValue: unknown) {
if (field.key === 'support_base64_input' || field.key === 'support_url_input') return '默认自动判断';
return defaultValue === true ? '默认支持' : '默认不支持';
}
function booleanValueFromSelect(value: string) {
if (value === 'true') return true;
if (value === 'false') return false;
return undefined;
}
function stringValue(value: unknown) {
return value === undefined || value === null ? '' : String(value);
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && !Array.isArray(value) && typeof value === 'object';
}
+288 -123
View File
@@ -4,91 +4,109 @@ import type {
BaseModelCatalogItem,
BaseModelUpsertRequest,
CatalogProvider,
PricingRuleSet,
RuntimePolicySet,
} from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select, Textarea } from '../../components/ui';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, FormItem, Label, Input, Select, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
import { BaseModelCapabilityEditor } from './BaseModelCapabilityEditor';
import {
addCapabilityType,
capabilitiesFromForm,
capabilitiesToForm,
createCapabilityEditorState,
platformModelTypeDefinitions,
removeCapabilityType,
syncPrimaryCapability,
type CapabilityEditorState,
} from './base-model-capabilities';
import { ModelCatalogCard } from './ModelCatalogCard';
type ModelForm = {
providerKey: string;
canonicalModelKey: string;
providerModelName: string;
modelType: string;
displayName: string;
modelAlias: string;
status: string;
pricingRuleSetId: string;
runtimePolicySetId: string;
runtimePolicyOverrideJson: string;
pricingVersion: string;
capabilitiesJson: string;
capabilities: CapabilityEditorState;
billingJson: string;
rateLimitJson: string;
metadataJson: string;
};
const statuses = ['active', 'deprecated', 'hidden'];
const fallbackTypes = [
'text_generate',
'image_generate',
'image_edit',
'image_analysis',
'video_generate',
'image_to_video',
'omni_video',
'text_embedding',
'text_to_speech',
'audio_generate',
'digital_human_generate',
'text_to_model',
'image_to_model',
'multiview_to_model',
'mesh_edit',
];
const fallbackTypes = platformModelTypeDefinitions.map((item) => item.key);
const emptyForm: ModelForm = {
providerKey: '',
canonicalModelKey: '',
providerModelName: '',
modelType: 'text_generate',
displayName: '',
status: 'active',
pricingVersion: '1',
capabilitiesJson: '{}',
billingJson: '{}',
rateLimitJson: '{}',
metadataJson: '{}',
};
function createEmptyForm(modelType = 'text_generate'): ModelForm {
return {
providerKey: '',
canonicalModelKey: '',
providerModelName: '',
modelAlias: '',
status: 'active',
pricingRuleSetId: '',
runtimePolicySetId: '',
runtimePolicyOverrideJson: '{}',
pricingVersion: '1',
capabilities: createCapabilityEditorState(modelType),
billingJson: '{}',
rateLimitJson: '{}',
metadataJson: '{}',
};
}
export function BaseModelCatalogPanel(props: {
baseModels: BaseModelCatalogItem[];
message: string;
pricingRuleSets: PricingRuleSet[];
providers: CatalogProvider[];
runtimePolicySets: RuntimePolicySet[];
state: LoadState;
onDeleteBaseModel: (baseModelId: string) => Promise<void>;
onResetAllBaseModels: () => Promise<void>;
onResetBaseModel: (baseModelId: string) => Promise<void>;
onSaveBaseModel: (input: BaseModelUpsertRequest, baseModelId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ModelForm>(emptyForm);
const [form, setForm] = useState<ModelForm>(() => createEmptyForm());
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const [providerFilter, setProviderFilter] = useState('all');
const [typeFilter, setTypeFilter] = useState('all');
const [pendingDeleteModel, setPendingDeleteModel] = useState<BaseModelCatalogItem | null>(null);
const [pendingResetModel, setPendingResetModel] = useState<BaseModelCatalogItem | null>(null);
const [pendingResetAll, setPendingResetAll] = useState(false);
const providerNames = useMemo(
() => new Map(props.providers.map((item) => [item.providerKey, item.displayName])),
[props.providers],
);
const typeOptions = useMemo(
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.map((item) => item.modelType).filter(Boolean)])),
() => Array.from(new Set([...fallbackTypes, ...props.baseModels.flatMap(readModelTypes)])),
[props.baseModels],
);
const providerOptions = useMemo(
() => Array.from(new Set([...props.providers.map((item) => item.providerKey), ...props.baseModels.map((item) => item.providerKey)])),
[props.baseModels, props.providers],
);
const defaultPricingRuleSetId = useMemo(() => defaultRuleSetId(props.pricingRuleSets), [props.pricingRuleSets]);
const defaultRuntimePolicySetId = useMemo(() => defaultRuntimePolicyId(props.runtimePolicySets), [props.runtimePolicySets]);
const systemModelCount = useMemo(() => props.baseModels.filter(isSystemBaseModel).length, [props.baseModels]);
const customizedSystemModelCount = useMemo(
() => props.baseModels.filter((item) => isSystemBaseModel(item) && item.customizedAt).length,
[props.baseModels],
);
const filteredModels = useMemo(() => {
const keyword = query.trim().toLowerCase();
return props.baseModels.filter((item) => {
const matchesProvider = providerFilter === 'all' || item.providerKey === providerFilter;
const matchesType = typeFilter === 'all' || readModelTypes(item).includes(typeFilter);
const text = `${item.displayName} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
const text = `${baseModelAlias(item)} ${item.providerModelName} ${item.canonicalModelKey}`.toLowerCase();
return matchesProvider && matchesType && (!keyword || text.includes(keyword));
});
}, [props.baseModels, providerFilter, query, typeFilter]);
@@ -107,34 +125,63 @@ export function BaseModelCatalogPanel(props: {
function openCreateDialog() {
const providerKey = providerOptions[0] ?? '';
setEditingId('');
setForm({ ...emptyForm, providerKey, canonicalModelKey: providerKey ? `${providerKey}:` : '' });
setForm({
...createEmptyForm(),
providerKey,
canonicalModelKey: providerKey ? `${providerKey}:` : '',
pricingRuleSetId: defaultPricingRuleSetId,
runtimePolicySetId: defaultRuntimePolicySetId,
});
setDialogOpen(true);
}
function editModel(model: BaseModelCatalogItem) {
setEditingId(model.id);
setForm(modelToForm(model));
setForm({
...modelToForm(model),
pricingRuleSetId: model.pricingRuleSetId || defaultPricingRuleSetId,
runtimePolicySetId: model.runtimePolicySetId || defaultRuntimePolicySetId,
});
setDialogOpen(true);
}
function closeDialog() {
setEditingId('');
setForm(emptyForm);
setForm(createEmptyForm());
setLocalError('');
setDialogOpen(false);
}
async function deleteModel(model: BaseModelCatalogItem) {
const confirmed = window.confirm(`确认删除基准模型 ${model.displayName}`);
if (!confirmed) return;
try {
await props.onDeleteBaseModel(model.id);
setPendingDeleteModel(null);
if (editingId === model.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型删除失败');
}
}
async function resetModel(model: BaseModelCatalogItem) {
try {
await props.onResetBaseModel(model.id);
setPendingResetModel(null);
if (editingId === model.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型重置失败');
}
}
async function resetAllModels() {
try {
await props.onResetAllBaseModels();
setPendingResetAll(false);
if (editingId) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '模型批量重置失败');
}
}
return (
<div className="pageStack">
<Card>
@@ -145,10 +192,16 @@ export function BaseModelCatalogPanel(props: {
<CardContent>
<div className="providerToolbar">
<p> server-main integration-platform</p>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
<div className="inlineActions">
<Button type="button" variant="outline" disabled={!systemModelCount || props.state === 'loading'} onClick={() => setPendingResetAll(true)}>
<RotateCcw size={15} />
</Button>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</div>
</div>
<div className="modelCatalogFilters">
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索模型名称 / canonical key" />
@@ -170,9 +223,12 @@ export function BaseModelCatalogPanel(props: {
<ModelCard
key={model.id}
model={model}
pricingRuleSetName={pricingRuleSetName(model.pricingRuleSetId, props.pricingRuleSets)}
providerName={providerNames.get(model.providerKey)}
onDelete={() => void deleteModel(model)}
runtimePolicySetName={runtimePolicySetName(model.runtimePolicySetId, props.runtimePolicySets)}
onDelete={() => setPendingDeleteModel(model)}
onEdit={() => editModel(model)}
onReset={() => setPendingResetModel(model)}
/>
))}
{!filteredModels.length && (
@@ -206,86 +262,130 @@ export function BaseModelCatalogPanel(props: {
onClose={closeDialog}
onSubmit={submit}
>
<Label>
Provider
<FormItem label="Provider">
<Select value={form.providerKey} onChange={(event) => setForm({ ...form, providerKey: event.target.value })}>
<option value=""></option>
{providerOptions.map((item) => <option value={item} key={item}>{providerNames.get(item) ?? item}</option>)}
</Select>
</Label>
<Label>
<Select value={form.modelType} onChange={(event) => setForm({ ...form, modelType: event.target.value })}>
{typeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
</FormItem>
<FormItem label="模型名">
<Input value={form.providerModelName} onChange={(event) => setForm({ ...form, providerModelName: event.target.value })} />
</Label>
<Label>
<Input value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} />
</Label>
<Label className="spanTwo">
Canonical Key
</FormItem>
<FormItem
description="作为调用和展示的模型名;多个不同平台模型设置同一别名后,可按该别名自动负载。"
label="模型别名"
>
<Input value={form.modelAlias} onChange={(event) => setForm({ ...form, modelAlias: event.target.value })} />
</FormItem>
<FormItem className="spanTwo" label="Canonical Key">
<Input value={form.canonicalModelKey} onChange={(event) => setForm({ ...form, canonicalModelKey: event.target.value })} />
</Label>
<Label>
</FormItem>
<FormItem label="状态">
<Select value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>
{statuses.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
</FormItem>
<FormItem label="计价规则">
<Select value={form.pricingRuleSetId || defaultPricingRuleSetId} onChange={(event) => setForm({ ...form, pricingRuleSetId: event.target.value })}>
{!props.pricingRuleSets.length && <option value=""></option>}
{props.pricingRuleSets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</FormItem>
<FormItem label="运行策略">
<Select value={form.runtimePolicySetId || defaultRuntimePolicySetId} onChange={(event) => setForm({ ...form, runtimePolicySetId: event.target.value })}>
{!props.runtimePolicySets.length && <option value=""></option>}
{props.runtimePolicySets.map((item) => <option value={item.id} key={item.id}>{item.name}</option>)}
</Select>
</FormItem>
<FormItem label="定价版本">
<Input value={form.pricingVersion} onChange={(event) => setForm({ ...form, pricingVersion: event.target.value })} />
</Label>
<JsonField label="能力 JSON" value={form.capabilitiesJson} onChange={(value) => setForm({ ...form, capabilitiesJson: value })} />
</FormItem>
<BaseModelCapabilityEditor
modelType={primaryCapabilityType(form.capabilities)}
typeOptions={typeOptions}
value={form.capabilities}
onChange={(capabilities) => setForm({ ...form, capabilities })}
/>
<JsonField label="基准计费 JSON" value={form.billingJson} onChange={(value) => setForm({ ...form, billingJson: value })} />
<JsonField label="限流 JSON" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
<JsonField label="旧版限流 JSON(兼容字段)" value={form.rateLimitJson} onChange={(value) => setForm({ ...form, rateLimitJson: value })} />
<JsonField label="运行策略覆盖 JSON" value={form.runtimePolicyOverrideJson} onChange={(value) => setForm({ ...form, runtimePolicyOverrideJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除模型"
description="删除后该基准模型不可恢复,已关联的平台模型可能无法继续跟随基准配置。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteModel)}
title={`确认删除基准模型 ${baseModelAlias(pendingDeleteModel)}`}
onCancel={() => setPendingDeleteModel(null)}
onConfirm={() => pendingDeleteModel ? deleteModel(pendingDeleteModel) : undefined}
/>
<ConfirmDialog
confirmLabel="重置为默认"
description="系统内置模型会恢复到默认模型库快照,当前手动修改的能力、计价、运行策略和元数据会被默认值覆盖。"
loading={props.state === 'loading'}
open={Boolean(pendingResetModel)}
title={`确认重置 ${baseModelAlias(pendingResetModel)}`}
onCancel={() => setPendingResetModel(null)}
onConfirm={() => pendingResetModel ? resetModel(pendingResetModel) : undefined}
/>
<ConfirmDialog
confirmLabel="重置所有"
description={`将恢复 ${systemModelCount} 个系统内置基准模型的默认快照,其中 ${customizedSystemModelCount} 个显示为已修改。用户添加的模型不会被影响。`}
loading={props.state === 'loading'}
open={pendingResetAll}
title="确认重置所有系统内置模型?"
onCancel={() => setPendingResetAll(false)}
onConfirm={resetAllModels}
/>
</div>
);
}
function ModelCard(props: {
model: BaseModelCatalogItem;
pricingRuleSetName: string;
providerName?: string;
runtimePolicySetName: string;
onDelete: () => void;
onEdit: () => void;
onReset: () => void;
}) {
const types = readModelTypes(props.model);
const chips = [...types.slice(0, 5), ...(types.length > 5 ? [`+${types.length - 5}`] : [])];
const isSystem = isSystemBaseModel(props.model);
return (
<article className="baseModelCard">
<ModelIcon model={props.model} />
<div className="baseModelCardBody">
<div>
<strong>{props.model.displayName}</strong>
<span>{props.model.providerModelName}</span>
</div>
<div className="providerCatalogMeta">
<Badge variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>
<span>{props.providerName ?? props.model.providerKey}</span>
<span>{props.model.canonicalModelKey}</span>
</div>
<div className="modelAbilityChips">
{types.slice(0, 5).map((item) => <span key={item}>{item}</span>)}
{types.length > 5 && <span>+{types.length - 5}</span>}
</div>
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
<Trash2 size={14} />
</Button>
</div>
</article>
<ModelCatalogCard
actions={(
<>
{isSystem && (
<Button type="button" variant="outline" size="sm" onClick={props.onReset}>
<RotateCcw size={14} />
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={props.onEdit}>
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={props.onDelete}>
<Trash2 size={14} />
</Button>
</>
)}
badges={[
<Badge key="status" variant={props.model.status === 'active' ? 'success' : 'secondary'}>{props.model.status}</Badge>,
<Badge key="catalog-type" variant={isSystem ? 'secondary' : 'outline'}>{baseModelCatalogTypeLabel(props.model)}</Badge>,
...(isSystem && props.model.customizedAt ? [<Badge key="customized" variant="warning"></Badge>] : []),
]}
chips={chips}
iconPath={readModelIconPath(props.model)}
iconText={props.model.providerKey}
meta={[props.providerName ?? props.model.providerKey, props.pricingRuleSetName, props.runtimePolicySetName, props.model.canonicalModelKey]}
subtitle={props.model.providerModelName}
title={baseModelAlias(props.model)}
/>
);
}
@@ -298,54 +398,115 @@ function JsonField(props: { label: string; value: string; onChange: (value: stri
);
}
function ModelIcon(props: { model: BaseModelCatalogItem }) {
const iconPath = readString(props.model.metadata?.iconPath) || readString(readRecord(props.model.metadata?.rawModel)?.icon_path);
if (iconPath) {
return (
<div className="providerCatalogLogo">
<img src={iconPath} alt="" />
</div>
);
}
return <div className="providerCatalogLogo">{props.model.providerKey.slice(0, 2).toUpperCase()}</div>;
}
function modelToForm(model: BaseModelCatalogItem): ModelForm {
const capabilities = readObject(model.capabilities);
const metadataTypes = Array.isArray(model.metadata?.originalTypes) ? { originalTypes: model.metadata.originalTypes } : {};
const modelTypes = readModelTypes(model);
const capabilityState = capabilitiesToForm({ ...capabilities, ...metadataTypes }, modelTypes[0] ?? 'text_generate');
return {
providerKey: model.providerKey,
canonicalModelKey: model.canonicalModelKey,
providerModelName: model.providerModelName,
modelType: model.modelType,
displayName: model.displayName,
modelAlias: baseModelAlias(model),
status: model.status,
pricingRuleSetId: model.pricingRuleSetId ?? '',
runtimePolicySetId: model.runtimePolicySetId ?? '',
pricingVersion: String(model.pricingVersion || 1),
capabilitiesJson: stringifyJson(model.capabilities),
capabilities: capabilityState,
billingJson: stringifyJson(model.baseBillingConfig),
rateLimitJson: stringifyJson(model.defaultRateLimitPolicy),
runtimePolicyOverrideJson: stringifyJson(model.runtimePolicyOverride),
metadataJson: stringifyJson(model.metadata),
};
}
function readModelIconPath(model: BaseModelCatalogItem) {
return readString(model.metadata?.iconPath) || readString(readRecord(model.metadata?.rawModel)?.icon_path);
}
function baseModelAlias(model?: BaseModelCatalogItem | null) {
return model?.modelAlias || model?.displayName || model?.providerModelName || '';
}
function primaryCapabilityType(capabilities: CapabilityEditorState) {
return capabilities.types[0] ?? 'text_generate';
}
function isSystemBaseModel(model: BaseModelCatalogItem) {
if (model.catalogType) return model.catalogType === 'system';
return model.metadata?.source === 'server-main.integration-platform' || Boolean(model.metadata?.rawModel || model.metadata?.sourceProviderCode);
}
function baseModelCatalogTypeLabel(model: BaseModelCatalogItem) {
return isSystemBaseModel(model) ? '系统内置' : '用户添加';
}
function formToPayload(form: ModelForm): BaseModelUpsertRequest {
const modelTypes = uniqueStrings(form.capabilities.types).filter(Boolean);
const primaryType = modelTypes[0] ?? 'text_generate';
const capabilities = syncCapabilityTypes(form.capabilities, modelTypes);
const modelAlias = form.modelAlias.trim() || form.providerModelName.trim();
const metadata = parseJsonObject(form.metadataJson, '元数据 JSON');
return {
providerKey: form.providerKey.trim(),
canonicalModelKey: form.canonicalModelKey.trim() || undefined,
providerModelName: form.providerModelName.trim(),
modelType: form.modelType.trim(),
displayName: form.displayName.trim() || form.providerModelName.trim(),
capabilities: parseJsonObject(form.capabilitiesJson, '能力 JSON'),
modelType: modelTypes.length ? modelTypes : [primaryType],
modelAlias,
capabilities: capabilitiesFromForm(syncPrimaryCapability(capabilities, primaryType)),
baseBillingConfig: parseJsonObject(form.billingJson, '基准计费 JSON'),
defaultRateLimitPolicy: parseJsonObject(form.rateLimitJson, '限流 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
pricingRuleSetId: form.pricingRuleSetId.trim() || undefined,
runtimePolicySetId: form.runtimePolicySetId.trim() || undefined,
runtimePolicyOverride: parseJsonObject(form.runtimePolicyOverrideJson, '运行策略覆盖 JSON'),
metadata: { ...metadata, alias: modelAlias, originalTypes: modelTypes },
pricingVersion: Number.parseInt(form.pricingVersion, 10) || 1,
status: form.status,
};
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
}
function syncCapabilityTypes(state: CapabilityEditorState, modelTypes: string[]): CapabilityEditorState {
const nextTypes = uniqueStrings(modelTypes).filter(Boolean);
if (!nextTypes.length) return state;
let next = state;
nextTypes.forEach((type) => {
next = addCapabilityType(next, type);
});
next.types
.filter((type) => !nextTypes.includes(type))
.forEach((type) => {
next = removeCapabilityType(next, type);
});
return { ...next, types: nextTypes };
}
function readModelTypes(model: BaseModelCatalogItem) {
if (Array.isArray(model.modelType)) return model.modelType.map(String);
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
if (Array.isArray(values)) return values.map(String);
return [model.modelType].filter(Boolean);
return [];
}
function defaultRuleSetId(ruleSets: PricingRuleSet[]) {
return ruleSets.find((item) => item.ruleSetKey === 'default-multimodal-v1')?.id ?? ruleSets[0]?.id ?? '';
}
function defaultRuntimePolicyId(policySets: RuntimePolicySet[]) {
return policySets.find((item) => item.policyKey === 'default-runtime-v1')?.id ?? policySets[0]?.id ?? '';
}
function pricingRuleSetName(ruleSetId: string | undefined, ruleSets: PricingRuleSet[]) {
if (!ruleSetId) return defaultRuleSetId(ruleSets) ? '默认计价规则' : '未绑定计价规则';
return ruleSets.find((item) => item.id === ruleSetId)?.name ?? '未知计价规则';
}
function runtimePolicySetName(policySetId: string | undefined, policySets: RuntimePolicySet[]) {
if (!policySetId) return defaultRuntimePolicyId(policySets) ? '默认运行策略' : '未绑定运行策略';
return policySets.find((item) => item.id === policySetId)?.name ?? '未知运行策略';
}
function parseJsonObject(value: string, label: string) {
@@ -361,10 +522,14 @@ function parseJsonObject(value: string, label: string) {
}
}
function stringifyJson(value?: Record<string, unknown>) {
function stringifyJson(value?: object) {
return JSON.stringify(value ?? {}, null, 2);
}
function readObject(value: unknown) {
return value && !Array.isArray(value) && typeof value === 'object' ? value as Record<string, unknown> : {};
}
function readString(value: unknown) {
return typeof value === 'string' ? value : '';
}
@@ -0,0 +1,775 @@
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Building2, KeyRound, Pencil, Plus, RotateCcw, Trash2, UserRound, UsersRound } from 'lucide-react';
import type {
GatewayTenant,
GatewayTenantUpsertRequest,
GatewayUser,
GatewayUserUpsertRequest,
UserGroup,
UserGroupUpsertRequest,
} from '@easyai-ai-gateway/contracts';
import {
Badge,
Button,
Card,
CardContent,
CardHeader,
CardTitle,
ConfirmDialog,
FormDialog,
Input,
Label,
Select,
Table,
TableCell,
TableHead,
TableRow,
Textarea,
} from '../../components/ui';
import type { ConsoleData } from '../../app-state';
import type { LoadState } from '../../types';
type TenantForm = {
tenantKey: string;
source: string;
externalTenantId: string;
name: string;
description: string;
defaultUserGroupId: string;
planKey: string;
billingProfileJson: string;
rateLimitPolicyJson: string;
authPolicyJson: string;
metadataJson: string;
status: string;
};
type UserForm = {
userKey: string;
source: string;
externalUserId: string;
username: string;
displayName: string;
email: string;
phone: string;
avatarUrl: string;
password: string;
gatewayTenantId: string;
tenantId: string;
tenantKey: string;
defaultUserGroupId: string;
roles: string;
authProfileJson: string;
metadataJson: string;
status: string;
};
type UserGroupForm = {
groupKey: string;
name: string;
description: string;
source: string;
priority: string;
rechargeDiscountPolicyJson: string;
billingDiscountPolicyJson: string;
rateLimitPolicyJson: string;
quotaPolicyJson: string;
metadataJson: string;
status: string;
};
const tenantStatuses = ['active', 'disabled', 'locked'];
const userStatuses = ['active', 'disabled', 'locked'];
const userGroupStatuses = ['active', 'disabled'];
const sourceOptions = ['gateway', 'server-main', 'sync'];
const roleOptions = [
{ label: '普通用户', value: 'user' },
{ label: '创作者', value: 'creator' },
{ label: '管理', value: 'operator' },
{ label: '超级管理员', value: 'manager' },
{ label: '管理员', value: 'admin' },
];
export function TenantsPanel(props: IdentityPanelProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<TenantForm>(() => defaultTenantForm());
const [localError, setLocalError] = useState('');
const [pendingDeleteTenant, setPendingDeleteTenant] = useState<GatewayTenant | null>(null);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(defaultTenantForm());
setDialogOpen(true);
}
function editTenant(tenant: GatewayTenant) {
setEditingId(tenant.id);
setLocalError('');
setForm(tenantToForm(tenant));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveTenant(formToTenantPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '保存租户失败');
}
}
async function deleteTenant(tenant: GatewayTenant) {
try {
await props.onDeleteTenant(tenant.id);
setPendingDeleteTenant(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '删除租户失败');
}
}
return (
<div className="pageStack">
<IdentityHeader
count={props.data.tenants.length}
description="租户可独立维护,也可承载 server-main 同步过来的租户标识。"
icon={<Building2 size={17} />}
message={localError || props.operationMessage}
title="租户管理"
actionLabel="新增租户"
onCreate={openCreateDialog}
/>
{props.data.tenants.length ? (
<Table className="identityDataTable tenantTable">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.data.tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell><IdentityName title={tenant.name} subtitle={tenant.tenantKey} /></TableCell>
<TableCell>{tenant.source}</TableCell>
<TableCell>{groupName(props.data.userGroups, tenant.defaultUserGroupId)}</TableCell>
<TableCell>{tenant.planKey || '未设置'}</TableCell>
<TableCell><Badge variant={tenant.status === 'active' ? 'success' : 'secondary'}>{tenant.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editTenant(tenant)} onDelete={() => setPendingDeleteTenant(tenant)} /></TableCell>
</TableRow>
))}
</Table>
) : (
<EmptyIdentity title="暂无租户" description="点击新增租户建立本地租户闭环。" />
)}
<FormDialog
ariaLabel={editingId ? '编辑租户' : '新增租户'}
className="identityDialog"
eyebrow={editingId ? 'Edit Tenant' : 'New Tenant'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
title={editingId ? '编辑租户' : '新增租户'}
onClose={closeDialog}
onSubmit={submit}
>
<Label> Key<Input size="sm" value={form.tenantKey} onChange={(event) => setForm({ ...form, tenantKey: event.target.value })} placeholder="default" /></Label>
<Label><Input size="sm" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="默认租户" /></Label>
<Label><Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
<Label><Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{tenantStatuses.map(option)}</Select></Label>
<Label> ID<Input size="sm" value={form.externalTenantId} onChange={(event) => setForm({ ...form, externalTenantId: event.target.value })} placeholder="server-main tenant id" /></Label>
<Label> Key<Input size="sm" value={form.planKey} onChange={(event) => setForm({ ...form, planKey: event.target.value })} placeholder="free / pro" /></Label>
<Label className="spanTwo"><Select size="sm" value={form.defaultUserGroupId} onChange={(event) => setForm({ ...form, defaultUserGroupId: event.target.value })}><option value=""></option>{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}</Select></Label>
<Label className="spanTwo"><Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} placeholder="内部备注" /></Label>
<JsonField label="计费资料 JSON" value={form.billingProfileJson} onChange={(value) => setForm({ ...form, billingProfileJson: value })} />
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
<JsonField label="授权策略 JSON" value={form.authPolicyJson} onChange={(value) => setForm({ ...form, authPolicyJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除租户"
description="租户会被软删除,关联用户不会被自动删除。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteTenant)}
title={`确认删除租户 ${pendingDeleteTenant?.name ?? ''}`}
onCancel={() => setPendingDeleteTenant(null)}
onConfirm={() => pendingDeleteTenant ? deleteTenant(pendingDeleteTenant) : undefined}
/>
</div>
);
}
export function UsersPanel(props: IdentityPanelProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<UserForm>(() => defaultUserForm(props.data.tenants[0]));
const [localError, setLocalError] = useState('');
const [pendingDeleteUser, setPendingDeleteUser] = useState<GatewayUser | null>(null);
const tenantById = useMemo(() => new Map(props.data.tenants.map((tenant) => [tenant.id, tenant])), [props.data.tenants]);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(defaultUserForm(props.data.tenants[0]));
setDialogOpen(true);
}
function editUser(user: GatewayUser) {
setEditingId(user.id);
setLocalError('');
setForm(userToForm(user));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveUser(formToUserPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '保存用户失败');
}
}
async function deleteUser(user: GatewayUser) {
try {
await props.onDeleteUser(user.id);
setPendingDeleteUser(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '删除用户失败');
}
}
function selectTenant(gatewayTenantId: string) {
const tenant = tenantById.get(gatewayTenantId);
setForm({ ...form, gatewayTenantId, tenantKey: tenant?.tenantKey ?? form.tenantKey });
}
return (
<div className="pageStack">
<IdentityHeader
count={props.data.users.length}
description="支持本地用户闭环,也保留 server-main 用户同步字段。"
icon={<UserRound size={17} />}
message={localError || props.operationMessage}
title="用户管理"
actionLabel="新增用户"
onCreate={openCreateDialog}
/>
{props.data.users.length ? (
<Table className="identityDataTable userTable">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.data.users.map((user) => (
<TableRow key={user.id}>
<TableCell><IdentityName title={user.displayName || user.username} subtitle={user.email || user.username} /></TableCell>
<TableCell>{roleLabel(user.roles)}</TableCell>
<TableCell>{tenantName(props.data.tenants, user.gatewayTenantId, user.tenantKey)}</TableCell>
<TableCell>{groupName(props.data.userGroups, user.defaultUserGroupId)}</TableCell>
<TableCell>{user.source}</TableCell>
<TableCell><Badge variant={user.status === 'active' ? 'success' : 'secondary'}>{user.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editUser(user)} onDelete={() => setPendingDeleteUser(user)} /></TableCell>
</TableRow>
))}
</Table>
) : (
<EmptyIdentity title="暂无用户" description="可先添加本地用户,后续再与 server-main 同步关系对齐。" />
)}
<FormDialog
ariaLabel={editingId ? '编辑用户' : '新增用户'}
className="identityDialog"
eyebrow={editingId ? 'Edit User' : 'New User'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
title={editingId ? '编辑用户' : '新增用户'}
onClose={closeDialog}
onSubmit={submit}
>
<Label><Input size="sm" value={form.username} onChange={(event) => setForm({ ...form, username: event.target.value })} placeholder="user@example.com" /></Label>
<Label> Key<Input size="sm" value={form.userKey} onChange={(event) => setForm({ ...form, userKey: event.target.value })} placeholder="留空自动生成" /></Label>
<Label><Input size="sm" value={form.displayName} onChange={(event) => setForm({ ...form, displayName: event.target.value })} placeholder="王小明" /></Label>
<Label><Input size="sm" value={form.email} onChange={(event) => setForm({ ...form, email: event.target.value })} placeholder="name@example.com" /></Label>
<Label><Input size="sm" value={form.phone} onChange={(event) => setForm({ ...form, phone: event.target.value })} /></Label>
<Label> URL<Input size="sm" value={form.avatarUrl} onChange={(event) => setForm({ ...form, avatarUrl: event.target.value })} /></Label>
<Label><Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
<Label><Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userStatuses.map(option)}</Select></Label>
<Label><Select size="sm" value={form.gatewayTenantId} onChange={(event) => selectTenant(event.target.value)}><option value=""></option>{props.data.tenants.map((tenant) => <option value={tenant.id} key={tenant.id}>{tenant.name}</option>)}</Select></Label>
<Label> Key<Input size="sm" value={form.tenantKey} onChange={(event) => setForm({ ...form, tenantKey: event.target.value })} /></Label>
<Label> ID<Input size="sm" value={form.externalUserId} onChange={(event) => setForm({ ...form, externalUserId: event.target.value })} /></Label>
<Label> ID<Input size="sm" value={form.tenantId} onChange={(event) => setForm({ ...form, tenantId: event.target.value })} /></Label>
<Label><Select size="sm" value={form.defaultUserGroupId} onChange={(event) => setForm({ ...form, defaultUserGroupId: event.target.value })}><option value=""></option>{props.data.userGroups.map((group) => <option value={group.id} key={group.id}>{group.name}</option>)}</Select></Label>
<Label><Input size="sm" value={form.password} type="password" onChange={(event) => setForm({ ...form, password: event.target.value })} placeholder={editingId ? '留空不修改' : '可为空'} /></Label>
<Label>
<Select size="sm" value={form.roles} onChange={(event) => setForm({ ...form, roles: event.target.value })}>
{roleOptions.map((role) => <option value={role.value} key={role.value}>{role.label}</option>)}
</Select>
</Label>
<JsonField label="授权资料 JSON" value={form.authProfileJson} onChange={(value) => setForm({ ...form, authProfileJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除用户"
description="用户会被软删除,已创建的任务记录会保留。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteUser)}
title={`确认删除用户 ${pendingDeleteUser?.username ?? ''}`}
onCancel={() => setPendingDeleteUser(null)}
onConfirm={() => pendingDeleteUser ? deleteUser(pendingDeleteUser) : undefined}
/>
</div>
);
}
export function UserGroupsPanel(props: IdentityPanelProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<UserGroupForm>(() => defaultUserGroupForm());
const [localError, setLocalError] = useState('');
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<UserGroup | null>(null);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(defaultUserGroupForm());
setDialogOpen(true);
}
function editGroup(group: UserGroup) {
setEditingId(group.id);
setLocalError('');
setForm(userGroupToForm(group));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveUserGroup(formToUserGroupPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '保存用户组失败');
}
}
async function deleteGroup(group: UserGroup) {
try {
await props.onDeleteUserGroup(group.id);
setPendingDeleteGroup(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '删除用户组失败');
}
}
return (
<div className="pageStack">
<IdentityHeader
count={props.data.userGroups.length}
description="用户组承载充值折扣、计费折扣、限流和额度策略。"
icon={<UsersRound size={17} />}
message={localError || props.operationMessage}
title="用户组管理"
actionLabel="新增用户组"
onCreate={openCreateDialog}
/>
{props.data.userGroups.length ? (
<Table className="identityDataTable groupTable">
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
{props.data.userGroups.map((group) => (
<TableRow key={group.id}>
<TableCell><IdentityName title={group.name} subtitle={group.groupKey} /></TableCell>
<TableCell>{group.source}</TableCell>
<TableCell>{group.priority}</TableCell>
<TableCell>{discountSummary(group)}</TableCell>
<TableCell>{policyKeys(group.rateLimitPolicy).join(', ') || '未设置'}</TableCell>
<TableCell><Badge variant={group.status === 'active' ? 'success' : 'secondary'}>{group.status}</Badge></TableCell>
<TableCell><TableActions onEdit={() => editGroup(group)} onDelete={() => setPendingDeleteGroup(group)} /></TableCell>
</TableRow>
))}
</Table>
) : (
<EmptyIdentity title="暂无用户组" description="可先新增默认用户组,用来配置折扣和并发控制。" />
)}
<FormDialog
ariaLabel={editingId ? '编辑用户组' : '新增用户组'}
className="identityDialog"
eyebrow={editingId ? 'Edit User Group' : 'New User Group'}
footer={<DialogFooter editing={Boolean(editingId)} loading={props.state === 'loading'} onCancel={closeDialog} />}
open={dialogOpen}
title={editingId ? '编辑用户组' : '新增用户组'}
onClose={closeDialog}
onSubmit={submit}
>
<Label> Key<Input size="sm" value={form.groupKey} onChange={(event) => setForm({ ...form, groupKey: event.target.value })} placeholder="default" /></Label>
<Label><Input size="sm" value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} placeholder="默认用户组" /></Label>
<Label><Select size="sm" value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })}>{sourceOptions.map(option)}</Select></Label>
<Label><Select size="sm" value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })}>{userGroupStatuses.map(option)}</Select></Label>
<Label><Input size="sm" value={form.priority} inputMode="numeric" onChange={(event) => setForm({ ...form, priority: event.target.value })} /></Label>
<Label className="spanTwo"><Input size="sm" value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<JsonField label="充值折扣策略 JSON" value={form.rechargeDiscountPolicyJson} onChange={(value) => setForm({ ...form, rechargeDiscountPolicyJson: value })} />
<JsonField label="计费折扣策略 JSON" value={form.billingDiscountPolicyJson} onChange={(value) => setForm({ ...form, billingDiscountPolicyJson: value })} />
<JsonField label="限流策略 JSON" value={form.rateLimitPolicyJson} onChange={(value) => setForm({ ...form, rateLimitPolicyJson: value })} />
<JsonField label="额度策略 JSON" value={form.quotaPolicyJson} onChange={(value) => setForm({ ...form, quotaPolicyJson: value })} />
<JsonField label="元数据 JSON" value={form.metadataJson} onChange={(value) => setForm({ ...form, metadataJson: value })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除用户组"
description="删除后租户和用户上引用该用户组的默认策略会失效。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteGroup)}
title={`确认删除用户组 ${pendingDeleteGroup?.name ?? ''}`}
onCancel={() => setPendingDeleteGroup(null)}
onConfirm={() => pendingDeleteGroup ? deleteGroup(pendingDeleteGroup) : undefined}
/>
</div>
);
}
type IdentityPanelProps = {
data: ConsoleData;
operationMessage: string;
state: LoadState;
onDeleteTenant: (tenantId: string) => Promise<void>;
onDeleteUser: (userId: string) => Promise<void>;
onDeleteUserGroup: (groupId: string) => Promise<void>;
onSaveTenant: (input: GatewayTenantUpsertRequest, tenantId?: string) => Promise<void>;
onSaveUser: (input: GatewayUserUpsertRequest, userId?: string) => Promise<void>;
onSaveUserGroup: (input: UserGroupUpsertRequest, groupId?: string) => Promise<void>;
};
function IdentityHeader(props: {
actionLabel: string;
count: number;
description: string;
icon: ReactNode;
message?: string;
title: string;
onCreate: () => void;
}) {
return (
<Card>
<CardHeader>
<div className="identityHeaderTitle">
<div className="iconBox">{props.icon}</div>
<div>
<CardTitle>{props.title}</CardTitle>
<p className="mutedText">{props.description}</p>
</div>
<Badge variant="secondary">{props.count}</Badge>
</div>
<Button type="button" onClick={props.onCreate}><Plus size={15} />{props.actionLabel}</Button>
</CardHeader>
{props.message && <CardContent><p className="formMessage">{props.message}</p></CardContent>}
</Card>
);
}
function IdentityName(props: { title: string; subtitle: string }) {
return (
<span className="identityTableName">
<strong>{props.title}</strong>
<small>{props.subtitle}</small>
</span>
);
}
function TableActions(props: { onDelete: () => void; onEdit: () => void }) {
return (
<span className="tableActions">
<Button type="button" variant="outline" size="icon" title="修改" onClick={props.onEdit}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="icon" title="删除" onClick={props.onDelete}><Trash2 size={14} /></Button>
</span>
);
}
function EmptyIdentity(props: { title: string; description: string }) {
return (
<Card>
<CardContent className="emptyState">
<KeyRound size={18} />
<strong>{props.title}</strong>
<span>{props.description}</span>
</CardContent>
</Card>
);
}
function DialogFooter(props: { editing: boolean; loading: boolean; onCancel: () => void }) {
return (
<>
<Button type="button" variant="outline" onClick={props.onCancel}><RotateCcw size={15} /></Button>
<Button type="submit" disabled={props.loading}>{props.editing ? <Pencil size={15} /> : <Plus size={15} />}{props.editing ? '保存修改' : '新增'}</Button>
</>
);
}
function JsonField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
{props.label}
<Textarea size="sm" rows={3} value={props.value} placeholder='{"rules":[]}' onChange={(event) => props.onChange(event.target.value)} />
</Label>
);
}
function option(value: string) {
return <option value={value} key={value}>{value}</option>;
}
function defaultTenantForm(): TenantForm {
return {
tenantKey: `tenant-${Date.now().toString(36)}`,
source: 'gateway',
externalTenantId: '',
name: '默认租户',
description: '',
defaultUserGroupId: '',
planKey: '',
billingProfileJson: '{}',
rateLimitPolicyJson: '{}',
authPolicyJson: '{}',
metadataJson: '{}',
status: 'active',
};
}
function tenantToForm(tenant: GatewayTenant): TenantForm {
return {
tenantKey: tenant.tenantKey,
source: tenant.source,
externalTenantId: tenant.externalTenantId ?? '',
name: tenant.name,
description: tenant.description ?? '',
defaultUserGroupId: tenant.defaultUserGroupId ?? '',
planKey: tenant.planKey ?? '',
billingProfileJson: stringifyJson(tenant.billingProfile),
rateLimitPolicyJson: stringifyJson(tenant.rateLimitPolicy),
authPolicyJson: stringifyJson(tenant.authPolicy),
metadataJson: stringifyJson(tenant.metadata),
status: tenant.status,
};
}
function formToTenantPayload(form: TenantForm): GatewayTenantUpsertRequest {
return {
tenantKey: form.tenantKey.trim(),
source: form.source,
externalTenantId: form.externalTenantId.trim() || undefined,
name: form.name.trim(),
description: form.description.trim() || undefined,
defaultUserGroupId: form.defaultUserGroupId || undefined,
planKey: form.planKey.trim() || undefined,
billingProfile: parseJsonObject(form.billingProfileJson, '计费资料 JSON'),
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
authPolicy: parseJsonObject(form.authPolicyJson, '授权策略 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
}
function defaultUserForm(tenant?: GatewayTenant): UserForm {
return {
userKey: '',
source: 'gateway',
externalUserId: '',
username: '',
displayName: '',
email: '',
phone: '',
avatarUrl: '',
password: '',
gatewayTenantId: tenant?.id ?? '',
tenantId: '',
tenantKey: tenant?.tenantKey ?? '',
defaultUserGroupId: '',
roles: 'user',
authProfileJson: '{}',
metadataJson: '{}',
status: 'active',
};
}
function userToForm(user: GatewayUser): UserForm {
return {
userKey: user.userKey,
source: user.source,
externalUserId: user.externalUserId ?? '',
username: user.username,
displayName: user.displayName ?? '',
email: user.email ?? '',
phone: user.phone ?? '',
avatarUrl: user.avatarUrl ?? '',
password: '',
gatewayTenantId: user.gatewayTenantId ?? '',
tenantId: user.tenantId ?? '',
tenantKey: user.tenantKey ?? '',
defaultUserGroupId: user.defaultUserGroupId ?? '',
roles: roleValue(user.roles),
authProfileJson: stringifyJson(user.authProfile),
metadataJson: stringifyJson(user.metadata),
status: user.status,
};
}
function formToUserPayload(form: UserForm): GatewayUserUpsertRequest {
return {
userKey: form.userKey.trim() || undefined,
source: form.source,
externalUserId: form.externalUserId.trim() || undefined,
username: form.username.trim(),
displayName: form.displayName.trim() || undefined,
email: form.email.trim() || undefined,
phone: form.phone.trim() || undefined,
avatarUrl: form.avatarUrl.trim() || undefined,
password: form.password || undefined,
gatewayTenantId: form.gatewayTenantId || undefined,
tenantId: form.tenantId.trim() || undefined,
tenantKey: form.tenantKey.trim() || undefined,
defaultUserGroupId: form.defaultUserGroupId || undefined,
roles: [roleValue([form.roles])],
authProfile: parseJsonObject(form.authProfileJson, '授权资料 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
}
function defaultUserGroupForm(): UserGroupForm {
return {
groupKey: `group-${Date.now().toString(36)}`,
name: '默认用户组',
description: '',
source: 'gateway',
priority: '100',
rechargeDiscountPolicyJson: '{}',
billingDiscountPolicyJson: '{}',
rateLimitPolicyJson: '{"rules":[]}',
quotaPolicyJson: '{}',
metadataJson: '{}',
status: 'active',
};
}
function userGroupToForm(group: UserGroup): UserGroupForm {
return {
groupKey: group.groupKey,
name: group.name,
description: group.description ?? '',
source: group.source,
priority: String(group.priority),
rechargeDiscountPolicyJson: stringifyJson(group.rechargeDiscountPolicy),
billingDiscountPolicyJson: stringifyJson(group.billingDiscountPolicy),
rateLimitPolicyJson: stringifyJson(group.rateLimitPolicy),
quotaPolicyJson: stringifyJson(group.quotaPolicy),
metadataJson: stringifyJson(group.metadata),
status: group.status,
};
}
function formToUserGroupPayload(form: UserGroupForm): UserGroupUpsertRequest {
return {
groupKey: form.groupKey.trim(),
name: form.name.trim(),
description: form.description.trim() || undefined,
source: form.source,
priority: Number(form.priority) || 100,
rechargeDiscountPolicy: parseJsonObject(form.rechargeDiscountPolicyJson, '充值折扣策略 JSON'),
billingDiscountPolicy: parseJsonObject(form.billingDiscountPolicyJson, '计费折扣策略 JSON'),
rateLimitPolicy: parseJsonObject(form.rateLimitPolicyJson, '限流策略 JSON'),
quotaPolicy: parseJsonObject(form.quotaPolicyJson, '额度策略 JSON'),
metadata: parseJsonObject(form.metadataJson, '元数据 JSON'),
status: form.status,
};
}
function tenantName(tenants: GatewayTenant[], tenantId?: string, fallback?: string) {
if (!tenantId) return fallback || '未设置';
return tenants.find((tenant) => tenant.id === tenantId)?.name ?? fallback ?? tenantId;
}
function groupName(groups: UserGroup[], groupId?: string) {
if (!groupId) return '未设置';
return groups.find((group) => group.id === groupId)?.name ?? groupId;
}
function roleValue(roles?: string[]) {
if (roles?.includes('manager')) return 'manager';
if (roles?.includes('admin')) return 'admin';
if (roles?.includes('operator')) return 'operator';
if (roles?.includes('creator')) return 'creator';
return 'user';
}
function roleLabel(roles?: string[]) {
return roleOptions.find((role) => role.value === roleValue(roles))?.label ?? '普通用户';
}
function discountSummary(group: UserGroup) {
const billing = group.billingDiscountPolicy?.discountFactor ?? group.billingDiscountPolicy?.factor;
const recharge = group.rechargeDiscountPolicy?.discountFactor ?? group.rechargeDiscountPolicy?.factor;
const parts = [];
if (billing) parts.push(`计费 ${billing}`);
if (recharge) parts.push(`充值 ${recharge}`);
return parts.join(' / ') || '未设置';
}
function policyKeys(value?: Record<string, unknown>) {
if (!value) return [];
return Object.keys(value).slice(0, 3);
}
function stringifyJson(value: unknown) {
if (!value || (typeof value === 'object' && Object.keys(value as Record<string, unknown>).length === 0)) return '{}';
return JSON.stringify(value, null, 2);
}
function parseJsonObject(value: string, label: string): Record<string, unknown> | undefined {
const text = value.trim();
if (!text || text === '{}') return undefined;
const parsed = JSON.parse(text) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
return parsed as Record<string, unknown>;
}
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react';
export function ModelCatalogCard(props: {
actions?: ReactNode;
badges?: ReactNode[];
chips?: string[];
iconPath?: string;
iconText: string;
meta?: string[];
subtitle: string;
title: string;
}) {
return (
<article className="baseModelCard">
<div className="providerCatalogLogo">
{props.iconPath ? <img src={props.iconPath} alt="" /> : props.iconText.slice(0, 2).toUpperCase()}
</div>
<div className="baseModelCardBody">
<div>
<strong>{props.title}</strong>
<span>{props.subtitle}</span>
</div>
<div className="providerCatalogMeta">
{props.badges?.map((badge, index) => <span key={`badge-${index}`}>{badge}</span>)}
{props.meta?.map((item) => <span key={item}>{item}</span>)}
</div>
{!!props.chips?.length && (
<div className="modelAbilityChips">
{props.chips.map((item) => <span key={item}>{item}</span>)}
</div>
)}
</div>
{props.actions && <div className="providerCatalogActions">{props.actions}</div>}
</article>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,9 @@
import { useMemo, useState } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useMemo, useState, type ReactNode } from 'react';
import { ListChecks, Plus, Trash2 } from 'lucide-react';
import type { PricingRuleInput } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Input, Label, Select } from '../../components/ui';
import { cn } from '../../lib/utils';
import { calculatorLabel, PricingFormulaNote, PricingInlineField, PricingReadonlyField, unitLabel } from './PricingEditorControls';
import { calculatorLabel, PricingFormulaNote, unitLabel } from './PricingEditorControls';
type RecordValue = Record<string, unknown>;
type ModeKey = 'text' | 'image' | 'video';
@@ -84,6 +84,8 @@ export function PricingRuleVisualEditor(props: {
if (mode.key === 'image' && rawActiveRules.length) return [completeRule(mergeTemplateRules(mode.templates(props.currency), rawActiveRules)[0], mode)];
return rawActiveRules.map((rule) => completeRule(rule, mode));
}, [mode.key, props.currency, rawActiveRules]);
const modeCounts = useMemo(() => modeRuleCounts(props.rules), [props.rules]);
const modeSummary = useMemo(() => summarizePricingMode(mode, activeRules), [activeRules, mode]);
function replaceModeRules(nextRules: PricingRuleInput[]) {
props.onChange([...props.rules.filter((rule) => !mode.match(rule)), ...nextRules]);
@@ -97,28 +99,48 @@ export function PricingRuleVisualEditor(props: {
<span></span>
</div>
</div>
<div className="pricingModeTabs">
{modeDefinitions.map((item) => (
<button className={item.key === activeMode ? 'active' : ''} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>{item.label}</button>
))}
</div>
{activeRules.length ? (
activeRules.map((rule, index) => (
<ModeRuleEditor
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
mode={mode}
rule={rule}
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
/>
))
) : (
<div className="pricingModeEmpty">
<strong>{mode.label} </strong>
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} /></Button>
<div className="pricingModeWorkbench">
<aside className="pricingModeSidebar">
<div className="pricingModeSidebarTitle">
<ListChecks size={15} />
<strong></strong>
</div>
<div className="pricingModeList">
{modeDefinitions.map((item) => (
<button data-active={item.key === activeMode} key={item.key} type="button" onClick={() => setActiveMode(item.key)}>
<span>
<strong>{item.label}</strong>
<small>{modeCounts[item.key] ?? 0} </small>
</span>
</button>
))}
</div>
<div className="pricingModeSummary">
<strong></strong>
<p>{modeSummary}</p>
</div>
</aside>
<div className="pricingModePanel">
{activeRules.length ? (
activeRules.map((rule, index) => (
<ModeRuleEditor
key={`${rule.ruleKey ?? rule.resourceType}-${index}`}
mode={mode}
rule={rule}
onChange={(nextRule) => replaceModeRules(activeRules.map((item, itemIndex) => (itemIndex === index ? nextRule : item)))}
onDelete={() => replaceModeRules(activeRules.filter((_, itemIndex) => itemIndex !== index))}
/>
))
) : (
<div className="pricingModeEmpty">
<strong>{mode.label} </strong>
<Button type="button" onClick={() => replaceModeRules(mode.templates(props.currency))}><Plus size={15} /></Button>
</div>
)}
</div>
)}
</div>
</section>
);
}
@@ -127,6 +149,38 @@ export function createDefaultPricingRules(currency: string): PricingRuleInput[]
return modeDefinitions.flatMap((mode) => mode.templates(currency).map((rule) => completeRule(rule, mode)));
}
function modeRuleCounts(rules: PricingRuleInput[]): Record<ModeKey, number> {
return Object.fromEntries(
modeDefinitions.map((mode) => [mode.key, mode.key === 'text' && rules.some(mode.match) ? 1 : rules.filter(mode.match).length]),
) as Record<ModeKey, number>;
}
function summarizePricingMode(mode: ModeDefinition, rules: PricingRuleInput[]) {
if (!rules.length) return `${mode.label}暂无计价配置,可一键添加默认规则。`;
const rule = rules[0];
if (mode.key === 'text') {
const prices = textPrices(rule);
return `文本按输入/输出 Token 分别计费,输入 ${prices.inputTokenPrice}/${unitLabel(rule.unit)},输出 ${prices.outputTokenPrice}/${unitLabel(rule.unit)}`;
}
const groupSummaries = mode.parameterGroups
.map((group) => summarizeWeightGroup(group, readGroup(rule.dynamicWeight, group)))
.filter(Boolean);
const details = groupSummaries.length ? `\n${groupSummaries.join('\n')}` : '';
return `${mode.label}${unitLabel(rule.unit)} 计费,基础单价 ${rule.basePrice},计算方式为${calculatorLabel(rule.calculatorType)}${details}`;
}
function summarizeWeightGroup(group: ParameterGroup, value: RecordValue) {
const items = Object.entries(value)
.map(([key, weight]) => `${summaryWeightLabel(group, key)} ×${scalarToString(weight)}`)
.join('、');
return items ? `${group.title}${items}` : '';
}
function summaryWeightLabel(group: ParameterGroup, key: string) {
const label = group.labels?.[key] ?? key;
return label.replace(/\s*\([^)]*\)\s*$/, '');
}
function ModeRuleEditor(props: {
mode: ModeDefinition;
rule: PricingRuleInput;
@@ -158,18 +212,19 @@ function ModeRuleEditor(props: {
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows compact">
<Label><Input value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} /></Label>
<Label><Select value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingModeInlineRows">
<PricingInlineField label={`基础单价/${unitLabel(rule.unit)}`}>
<Input min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
</PricingInlineField>
<PricingReadonlyField label="计价单位" value={unitLabel(rule.unit)} />
<PricingReadonlyField label="计算方式" value={calculatorLabel(rule.calculatorType)} />
</div>
<PricingFormRows>
<PricingFormRow label="展示名称">
<Input size="sm" value={rule.displayName ?? ''} onChange={(event) => patch({ displayName: event.target.value })} />
</PricingFormRow>
<PricingFormRow label="状态">
<Select size="sm" value={rule.status ?? 'active'} onChange={(event) => patch({ status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
</PricingFormRow>
<PricingFormRow label={`基础单价/${unitLabel(rule.unit)}`}>
<Input size="sm" min="0" step="0.0001" type="number" value={rule.basePrice} onChange={(event) => patch({ basePrice: Number(event.target.value) })} />
</PricingFormRow>
<PricingReadonlyRow label="计价单位" value={unitLabel(rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(rule.calculatorType)} />
</PricingFormRows>
<PricingFormulaNote>{props.mode.formula}</PricingFormulaNote>
@@ -238,25 +293,50 @@ function TextRuleEditor(props: {
<Button type="button" variant="ghost" size="sm" onClick={props.onDelete}><Trash2 size={14} /></Button>
</header>
<div className="pricingModeBaseRows compact">
<Label><Input value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} /></Label>
<Label><Select value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select></Label>
</div>
<div className="pricingTextPriceGrid">
<PricingInlineField label="输入单价/1K tokens">
<Input min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
</PricingInlineField>
<PricingInlineField label="输出单价/1K tokens">
<Input min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
</PricingInlineField>
</div>
<PricingFormRows>
<PricingFormRow label="展示名称">
<Input size="sm" value={props.rule.displayName ?? '文本'} onChange={(event) => props.onChange({ ...props.rule, displayName: event.target.value, ruleKey: 'text' })} />
</PricingFormRow>
<PricingFormRow label="状态">
<Select size="sm" value={props.rule.status ?? 'active'} onChange={(event) => props.onChange({ ...props.rule, status: event.target.value })}>{statuses.map((item) => <option key={item} value={item}>{item}</option>)}</Select>
</PricingFormRow>
<PricingFormRow label="输入单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.inputTokenPrice} onChange={(event) => updatePrices(Number(event.target.value), prices.outputTokenPrice)} />
</PricingFormRow>
<PricingFormRow label="输出单价/1K tokens">
<Input size="sm" min="0" step="0.0001" type="number" value={prices.outputTokenPrice} onChange={(event) => updatePrices(prices.inputTokenPrice, Number(event.target.value))} />
</PricingFormRow>
<PricingReadonlyRow label="计价单位" value={unitLabel(props.rule.unit)} />
<PricingReadonlyRow label="计算方式" value={calculatorLabel(props.rule.calculatorType)} />
</PricingFormRows>
<PricingFormulaNote>{props.formula}</PricingFormulaNote>
</article>
);
}
function PricingFormRows(props: { children: ReactNode }) {
return <div className="pricingFormRows">{props.children}</div>;
}
function PricingFormRow(props: { children: ReactNode; label: string }) {
return (
<div className="pricingFormRow">
<strong>{props.label}</strong>
{props.children}
</div>
);
}
function PricingReadonlyRow(props: { label: string; value: string }) {
return (
<div className="pricingFormRow">
<strong>{props.label}</strong>
<span className="pricingFormReadonly">{props.value}</span>
</div>
);
}
function WeightTable(props: {
editableTitle?: boolean;
labels?: Record<string, string>;
+31 -4
View File
@@ -1,7 +1,7 @@
import { useMemo, useState, type FormEvent } from 'react';
import { FileText, Image, Pencil, Plus, RotateCcw, Trash2, Video } from 'lucide-react';
import type { PricingRule, PricingRuleInput, PricingRuleSet, PricingRuleSetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
import { unitLabel } from './PricingEditorControls';
import { createDefaultPricingRules, KeyValueEditor, PricingRuleVisualEditor } from './PricingRuleVisualEditor';
@@ -33,6 +33,7 @@ export function PricingRulesPanel(props: {
const [form, setForm] = useState<PricingForm>(() => createEmptyForm());
const [localError, setLocalError] = useState('');
const [query, setQuery] = useState('');
const [pendingDeleteRuleSet, setPendingDeleteRuleSet] = useState<PricingRuleSet | null>(null);
const filtered = useMemo(() => {
const keyword = query.trim().toLowerCase();
if (!keyword) return props.pricingRuleSets;
@@ -76,10 +77,13 @@ export function PricingRulesPanel(props: {
}
async function deleteRuleSet(ruleSet: PricingRuleSet) {
const confirmed = window.confirm(`确认删除定价规则 ${ruleSet.name}?已绑定的平台或模型会清空规则绑定。`);
if (!confirmed) return;
if (isDefaultRuleSet(ruleSet)) {
setLocalError('默认计价规则不能删除。');
return;
}
try {
await props.onDeletePricingRuleSet(ruleSet.id);
setPendingDeleteRuleSet(null);
if (editingId === ruleSet.id) closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '定价规则删除失败');
@@ -131,7 +135,17 @@ export function PricingRulesPanel(props: {
</div>
<div className="providerCatalogActions">
<Button type="button" variant="outline" size="sm" onClick={() => editRuleSet(ruleSet)}><Pencil size={14} /></Button>
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteRuleSet(ruleSet)}><Trash2 size={14} /></Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultRuleSet(ruleSet)}
title={isDefaultRuleSet(ruleSet) ? '默认计价规则不能删除' : undefined}
onClick={() => setPendingDeleteRuleSet(ruleSet)}
>
<Trash2 size={14} />
</Button>
</div>
</article>
))}
@@ -165,6 +179,15 @@ export function PricingRulesPanel(props: {
<PricingRuleVisualEditor currency={form.currency} rules={form.rules} onChange={(rules) => setForm({ ...form, rules })} />
<KeyValueEditor className="spanTwo" title="规则集元数据" value={form.metadata} onChange={(metadata) => setForm({ ...form, metadata })} />
</FormDialog>
<ConfirmDialog
confirmLabel="删除规则"
description="已绑定的平台或模型会清空规则绑定,删除后不可恢复。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteRuleSet)}
title={`确认删除定价规则 ${pendingDeleteRuleSet?.name ?? ''}`}
onCancel={() => setPendingDeleteRuleSet(null)}
onConfirm={() => pendingDeleteRuleSet ? deleteRuleSet(pendingDeleteRuleSet) : undefined}
/>
</div>
);
}
@@ -273,6 +296,10 @@ function formatPrice(value: number) {
return Number(value.toFixed(4)).toString();
}
function isDefaultRuleSet(ruleSet: PricingRuleSet) {
return ruleSet.ruleSetKey === 'default-multimodal-v1';
}
function ensureRules(rules: PricingRuleInput[], currency: string): PricingRuleInput[] {
const sourceRules = rules.length ? rules : createDefaultPricingRules(currency);
return sourceRules.map((rule) => ({ ...rule, currency }));
@@ -1,7 +1,7 @@
import { useMemo, useState, type FormEvent } from 'react';
import { Pencil, Plus, RotateCcw, Trash2 } from 'lucide-react';
import type { CatalogProvider, CatalogProviderUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, FormDialog, Input, Label, Select } from '../../components/ui';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Select } from '../../components/ui';
import type { LoadState } from '../../types';
type ProviderForm = {
@@ -9,6 +9,8 @@ type ProviderForm = {
displayName: string;
providerType: string;
iconPath: string;
defaultBaseUrl: string;
defaultAuthType: string;
source: string;
status: string;
};
@@ -18,6 +20,8 @@ const emptyForm: ProviderForm = {
displayName: '',
providerType: 'openai',
iconPath: '',
defaultBaseUrl: '',
defaultAuthType: 'APIKey',
source: 'gateway',
status: 'active',
};
@@ -51,6 +55,7 @@ const defaultSpecTypes = [
'n8n',
];
const providerStatuses = ['active', 'deprecated', 'hidden'];
const authTypeOptions = ['APIKey', 'Token', 'AccessKey-SecretKey', 'none'];
export function ProviderManagementPanel(props: {
message: string;
@@ -62,6 +67,7 @@ export function ProviderManagementPanel(props: {
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<ProviderForm>(emptyForm);
const [dialogOpen, setDialogOpen] = useState(false);
const [pendingDeleteProvider, setPendingDeleteProvider] = useState<CatalogProvider | null>(null);
const editingProvider = useMemo(
() => props.providers.find((item) => item.id === editingId),
[editingId, props.providers],
@@ -95,6 +101,8 @@ export function ProviderManagementPanel(props: {
displayName: provider.displayName,
providerType: provider.providerType,
iconPath: provider.iconPath ?? '',
defaultBaseUrl: provider.defaultBaseUrl ?? '',
defaultAuthType: provider.defaultAuthType ?? 'APIKey',
source: provider.source ?? 'gateway',
status: provider.status,
});
@@ -108,10 +116,9 @@ export function ProviderManagementPanel(props: {
}
async function deleteProvider(provider: CatalogProvider) {
const confirmed = window.confirm(`确认删除模型厂商 ${provider.displayName}`);
if (!confirmed) return;
try {
await props.onDeleteProvider(provider.id);
setPendingDeleteProvider(null);
if (editingId === provider.id) {
closeDialog();
}
@@ -129,7 +136,7 @@ export function ProviderManagementPanel(props: {
</CardHeader>
<CardContent>
<div className="providerToolbar">
<p> server-main integration-platformcode </p>
<p> server-main integration-platformcodeBase URL </p>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
@@ -151,6 +158,8 @@ export function ProviderManagementPanel(props: {
<div className="providerCatalogMeta">
<Badge variant={provider.status === 'active' ? 'success' : 'secondary'}>{provider.status}</Badge>
<span>spec_type: {provider.providerType}</span>
<span>{provider.defaultAuthType ?? 'APIKey'}</span>
{provider.defaultBaseUrl && <span>{provider.defaultBaseUrl}</span>}
<span>{provider.source ?? 'gateway'}</span>
</div>
</div>
@@ -159,7 +168,7 @@ export function ProviderManagementPanel(props: {
<Pencil size={14} />
</Button>
<Button type="button" variant="destructive" size="sm" onClick={() => void deleteProvider(provider)}>
<Button type="button" variant="destructive" size="sm" onClick={() => setPendingDeleteProvider(provider)}>
<Trash2 size={14} />
</Button>
@@ -213,6 +222,16 @@ export function ProviderManagementPanel(props: {
URL
<Input value={form.iconPath} onChange={(event) => setForm({ ...form, iconPath: event.target.value })} placeholder="https://static.51easyai.com/xxx.png" />
</Label>
<Label className="spanTwo">
Base URL
<Input value={form.defaultBaseUrl} onChange={(event) => setForm({ ...form, defaultBaseUrl: event.target.value })} placeholder="https://api.example.com/v1" />
</Label>
<Label>
<Select value={form.defaultAuthType} onChange={(event) => setForm({ ...form, defaultAuthType: event.target.value })}>
{authTypeOptions.map((item) => <option value={item} key={item}>{item}</option>)}
</Select>
</Label>
<Label>
<Input value={form.source} onChange={(event) => setForm({ ...form, source: event.target.value })} />
@@ -224,6 +243,15 @@ export function ProviderManagementPanel(props: {
</Select>
</Label>
</FormDialog>
<ConfirmDialog
confirmLabel="删除厂商"
description="删除模型厂商后不可恢复,请确认没有仍需保留的模型目录数据。"
loading={props.state === 'loading'}
open={Boolean(pendingDeleteProvider)}
title={`确认删除模型厂商 ${pendingDeleteProvider?.displayName ?? ''}`}
onCancel={() => setPendingDeleteProvider(null)}
onConfirm={() => pendingDeleteProvider ? deleteProvider(pendingDeleteProvider) : undefined}
/>
</div>
);
}
@@ -247,6 +275,8 @@ function providerPayload(form: ProviderForm, current?: CatalogProvider): Catalog
displayName: form.displayName.trim(),
providerType: form.providerType.trim() || 'openai',
iconPath: form.iconPath.trim(),
defaultBaseUrl: form.defaultBaseUrl.trim(),
defaultAuthType: form.defaultAuthType.trim() || 'APIKey',
source: form.source.trim() || 'gateway',
status: form.status,
capabilitySchema: current?.capabilitySchema ?? {},
@@ -0,0 +1,385 @@
import { useState, type FormEvent } from 'react';
import { Gauge, Pencil, Plus, RotateCcw, ShieldCheck, Trash2 } from 'lucide-react';
import type { RuntimePolicySet, RuntimePolicySetUpsertRequest } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, FormDialog, Input, Label, Textarea } from '../../components/ui';
import type { LoadState } from '../../types';
type RuntimePolicyForm = {
policyKey: string;
name: string;
description: string;
rpm: string;
tpm: string;
concurrency: string;
retryEnabled: boolean;
retryMaxAttempts: string;
retryAllowKeywords: string;
retryDenyKeywords: string;
autoDisableEnabled: boolean;
autoDisableThreshold: string;
autoDisableKeywords: string;
degradeEnabled: boolean;
degradeCooldownSeconds: string;
degradeKeywords: string;
metadataJson: string;
status: string;
};
export function RuntimePoliciesPanel(props: {
message: string;
runtimePolicySets: RuntimePolicySet[];
state: LoadState;
onDeleteRuntimePolicySet: (policySetId: string) => Promise<void>;
onSaveRuntimePolicySet: (input: RuntimePolicySetUpsertRequest, policySetId?: string) => Promise<void>;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState('');
const [form, setForm] = useState<RuntimePolicyForm>(() => createDefaultForm());
const [localError, setLocalError] = useState('');
const [pendingDeletePolicy, setPendingDeletePolicy] = useState<RuntimePolicySet | null>(null);
function openCreateDialog() {
setEditingId('');
setLocalError('');
setForm(createDefaultForm(`runtime-${Date.now().toString(36)}`));
setDialogOpen(true);
}
function editPolicy(policy: RuntimePolicySet) {
setEditingId(policy.id);
setLocalError('');
setForm(policyToForm(policy));
setDialogOpen(true);
}
function closeDialog() {
setDialogOpen(false);
setEditingId('');
setLocalError('');
setForm(createDefaultForm());
}
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setLocalError('');
try {
await props.onSaveRuntimePolicySet(formToPayload(form), editingId || undefined);
closeDialog();
} catch (err) {
setLocalError(err instanceof Error ? err.message : '运行策略保存失败');
}
}
async function deletePolicy(policy: RuntimePolicySet) {
if (isDefaultPolicy(policy)) return;
try {
await props.onDeleteRuntimePolicySet(policy.id);
setPendingDeletePolicy(null);
} catch (err) {
setLocalError(err instanceof Error ? err.message : '运行策略删除失败');
}
}
return (
<div className="pageStack">
<Card>
<CardHeader>
<div>
<CardTitle></CardTitle>
<p className="mutedText"></p>
</div>
<Button type="button" onClick={openCreateDialog}>
<Plus size={15} />
</Button>
</CardHeader>
<CardContent>
{(props.message || localError) && <p className="formMessage">{localError || props.message}</p>}
</CardContent>
</Card>
<section className="runtimePolicyGrid">
{props.runtimePolicySets.map((policy) => (
<article className="runtimePolicyCard" key={policy.id}>
<header>
<div className="iconBox"><ShieldCheck size={18} /></div>
<div>
<strong>{policy.name}</strong>
<span>{policy.policyKey}</span>
</div>
<Badge variant={policy.status === 'active' ? 'success' : 'secondary'}>{policy.status}</Badge>
</header>
{policy.description && <p>{policy.description}</p>}
<div className="runtimePolicySummary">
<span><Gauge size={13} />{rateLimitSummary(policy)}</span>
<span>{retrySummary(policy)}</span>
<span>{autoDisableSummary(policy)}</span>
<span>{degradeSummary(policy)}</span>
</div>
<footer>
<Button type="button" variant="outline" size="sm" onClick={() => editPolicy(policy)}>
<Pencil size={14} />
</Button>
<Button
type="button"
variant="destructive"
size="sm"
disabled={isDefaultPolicy(policy)}
title={isDefaultPolicy(policy) ? '默认运行策略不能删除' : undefined}
onClick={() => setPendingDeletePolicy(policy)}
>
<Trash2 size={14} />
</Button>
</footer>
</article>
))}
</section>
<FormDialog
bodyClassName="runtimePolicyFormBody"
className="runtimePolicyDialog"
eyebrow={editingId ? 'Edit Runtime Policy' : 'New Runtime Policy'}
footer={(
<>
<Button type="button" variant="outline" onClick={closeDialog}>
<RotateCcw size={15} />
</Button>
<Button type="submit" disabled={props.state === 'loading'}>
{editingId ? <Pencil size={15} /> : <Plus size={15} />}
{editingId ? '保存修改' : '新增策略'}
</Button>
</>
)}
open={dialogOpen}
title={editingId ? '编辑运行策略' : '新增运行策略'}
onClose={closeDialog}
onSubmit={submit}
>
<Label> Key<Input value={form.policyKey} onChange={(event) => setForm({ ...form, policyKey: event.target.value })} /></Label>
<Label><Input value={form.name} onChange={(event) => setForm({ ...form, name: event.target.value })} /></Label>
<Label className="spanTwo"><Input value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></Label>
<section className="runtimePolicySection spanTwo">
<header><strong></strong><span>TPM / RPM / </span></header>
<div className="runtimePolicyRows">
<Label>RPM / <Input value={form.rpm} inputMode="numeric" onChange={(event) => setForm({ ...form, rpm: event.target.value })} /></Label>
<Label>TPM / Token<Input value={form.tpm} inputMode="numeric" onChange={(event) => setForm({ ...form, tpm: event.target.value })} /></Label>
<Label><Input value={form.concurrency} inputMode="numeric" onChange={(event) => setForm({ ...form, concurrency: event.target.value })} /></Label>
</div>
</section>
<section className="runtimePolicySection spanTwo">
<header><strong></strong><span>/</span></header>
<div className="runtimePolicyRows">
<Toggle checked={form.retryEnabled} label="允许失败重试" onChange={(checked) => setForm({ ...form, retryEnabled: checked })} />
<Label><Input value={form.retryMaxAttempts} inputMode="numeric" onChange={(event) => setForm({ ...form, retryMaxAttempts: event.target.value })} /></Label>
<KeywordField label="允许重试关键词" value={form.retryAllowKeywords} onChange={(value) => setForm({ ...form, retryAllowKeywords: value })} />
<KeywordField label="拒绝重试关键词" value={form.retryDenyKeywords} onChange={(value) => setForm({ ...form, retryDenyKeywords: value })} />
</div>
</section>
<section className="runtimePolicySection spanTwo">
<header><strong></strong><span></span></header>
<div className="runtimePolicyRows">
<Toggle checked={form.autoDisableEnabled} label="启用自动禁用" onChange={(checked) => setForm({ ...form, autoDisableEnabled: checked })} />
<Label><Input value={form.autoDisableThreshold} inputMode="numeric" onChange={(event) => setForm({ ...form, autoDisableThreshold: event.target.value })} /></Label>
<KeywordField label="自动禁用关键词" value={form.autoDisableKeywords} onChange={(value) => setForm({ ...form, autoDisableKeywords: value })} />
<Toggle checked={form.degradeEnabled} label="启用优先级降级" onChange={(checked) => setForm({ ...form, degradeEnabled: checked })} />
<Label><Input value={form.degradeCooldownSeconds} inputMode="numeric" onChange={(event) => setForm({ ...form, degradeCooldownSeconds: event.target.value })} /></Label>
<KeywordField label="降级关键词" value={form.degradeKeywords} onChange={(value) => setForm({ ...form, degradeKeywords: value })} />
</div>
</section>
<Label><Input value={form.status} onChange={(event) => setForm({ ...form, status: event.target.value })} /></Label>
<Label className="spanTwo"> JSON<Textarea value={form.metadataJson} rows={4} onChange={(event) => setForm({ ...form, metadataJson: event.target.value })} /></Label>
</FormDialog>
<ConfirmDialog
confirmLabel="删除策略"
description="已绑定模型会清空策略绑定,删除后不可恢复。"
loading={props.state === 'loading'}
open={Boolean(pendingDeletePolicy)}
title={`确认删除运行策略 ${pendingDeletePolicy?.name ?? ''}`}
onCancel={() => setPendingDeletePolicy(null)}
onConfirm={() => pendingDeletePolicy ? deletePolicy(pendingDeletePolicy) : undefined}
/>
</div>
);
}
function Toggle(props: { checked: boolean; label: string; onChange: (checked: boolean) => void }) {
return (
<label className="platformToggle">
<input type="checkbox" checked={props.checked} onChange={(event) => props.onChange(event.target.checked)} />
<span><strong>{props.label}</strong></span>
</label>
);
}
function KeywordField(props: { label: string; value: string; onChange: (value: string) => void }) {
return (
<Label className="spanTwo">
{props.label}
<Input value={props.value} placeholder="多个关键词用逗号或换行分隔" onChange={(event) => props.onChange(event.target.value)} />
</Label>
);
}
function createDefaultForm(policyKey = 'default-runtime-v1'): RuntimePolicyForm {
return {
policyKey,
name: policyKey === 'default-runtime-v1' ? '默认运行策略' : '',
description: '',
rpm: '120',
tpm: '240000',
concurrency: '6',
retryEnabled: true,
retryMaxAttempts: '2',
retryAllowKeywords: 'rate_limit, timeout, server_error, network, 429, 5xx',
retryDenyKeywords: 'invalid_api_key, insufficient_quota, billing_not_active, permission_denied',
autoDisableEnabled: false,
autoDisableThreshold: '3',
autoDisableKeywords: 'invalid_api_key, account_deactivated, permission_denied, billing_not_active',
degradeEnabled: true,
degradeCooldownSeconds: '300',
degradeKeywords: 'rate_limit, quota, timeout, temporarily_unavailable, overloaded',
metadataJson: '{}',
status: 'active',
};
}
function policyToForm(policy: RuntimePolicySet): RuntimePolicyForm {
const rateRules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
const retry = readObject(policy.retryPolicy);
const disable = readObject(policy.autoDisablePolicy);
const degrade = readObject(policy.degradePolicy);
return {
policyKey: policy.policyKey,
name: policy.name,
description: policy.description ?? '',
rpm: String(readRateLimit(rateRules, 'rpm') || ''),
tpm: String(readRateLimit(rateRules, 'tpm_total') || ''),
concurrency: String(readRateLimit(rateRules, 'concurrent') || ''),
retryEnabled: readBool(retry.enabled, true),
retryMaxAttempts: String(readNumber(retry.maxAttempts, 2)),
retryAllowKeywords: stringifyKeywords(retry.allowKeywords),
retryDenyKeywords: stringifyKeywords(retry.denyKeywords),
autoDisableEnabled: readBool(disable.enabled, false),
autoDisableThreshold: String(readNumber(disable.threshold, 3)),
autoDisableKeywords: stringifyKeywords(disable.keywords),
degradeEnabled: readBool(degrade.enabled, true),
degradeCooldownSeconds: String(readNumber(degrade.cooldownSeconds, 300)),
degradeKeywords: stringifyKeywords(degrade.keywords),
metadataJson: JSON.stringify(policy.metadata ?? {}, null, 2),
status: policy.status || 'active',
};
}
function formToPayload(form: RuntimePolicyForm): RuntimePolicySetUpsertRequest {
return {
policyKey: form.policyKey.trim(),
name: form.name.trim(),
description: form.description.trim() || undefined,
rateLimitPolicy: { rules: rateLimitRules(form) },
retryPolicy: {
enabled: form.retryEnabled,
maxAttempts: positiveInt(form.retryMaxAttempts, 2),
allowKeywords: parseKeywords(form.retryAllowKeywords),
denyKeywords: parseKeywords(form.retryDenyKeywords),
},
autoDisablePolicy: {
enabled: form.autoDisableEnabled,
threshold: positiveInt(form.autoDisableThreshold, 3),
keywords: parseKeywords(form.autoDisableKeywords),
},
degradePolicy: {
enabled: form.degradeEnabled,
cooldownSeconds: positiveInt(form.degradeCooldownSeconds, 300),
keywords: parseKeywords(form.degradeKeywords),
},
metadata: parseJson(form.metadataJson),
status: form.status.trim() || 'active',
};
}
function rateLimitRules(form: RuntimePolicyForm) {
return [
limitRule('rpm', form.rpm),
limitRule('tpm_total', form.tpm),
limitRule('concurrent', form.concurrency, 60, 120),
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
}
function limitRule(metric: string, value: string, windowSeconds = 60, leaseTtlSeconds?: number) {
const limit = Number(value);
if (!Number.isFinite(limit) || limit <= 0) return undefined;
return { metric, limit, windowSeconds, leaseTtlSeconds };
}
function isDefaultPolicy(policy: RuntimePolicySet) {
return policy.policyKey === 'default-runtime-v1';
}
function rateLimitSummary(policy: RuntimePolicySet) {
const rules = Array.isArray(policy.rateLimitPolicy?.rules) ? policy.rateLimitPolicy.rules : [];
const rpm = readRateLimit(rules, 'rpm') || '-';
const tpm = readRateLimit(rules, 'tpm_total') || '-';
const concurrent = readRateLimit(rules, 'concurrent') || '-';
return `RPM ${rpm} / TPM ${tpm} / 并发 ${concurrent}`;
}
function retrySummary(policy: RuntimePolicySet) {
const retry = readObject(policy.retryPolicy);
return readBool(retry.enabled, false) ? `重试 ${readNumber(retry.maxAttempts, 2)}` : '不重试';
}
function autoDisableSummary(policy: RuntimePolicySet) {
const disable = readObject(policy.autoDisablePolicy);
return readBool(disable.enabled, false) ? `自动禁用: ${stringifyKeywords(disable.keywords) || '-'}` : '自动禁用关闭';
}
function degradeSummary(policy: RuntimePolicySet) {
const degrade = readObject(policy.degradePolicy);
return readBool(degrade.enabled, false) ? `降级: ${stringifyKeywords(degrade.keywords) || '-'}` : '降级关闭';
}
function readRateLimit(rules: unknown[], metric: string) {
const rule = rules.find((item) => readObject(item).metric === metric);
return readNumber(readObject(rule).limit, 0);
}
function parseKeywords(value: string) {
return value.split(/[,\n]/).map((item) => item.trim()).filter(Boolean);
}
function stringifyKeywords(value: unknown) {
return Array.isArray(value) ? value.map(String).join(', ') : '';
}
function positiveInt(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function readNumber(value: unknown, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
function readBool(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
function readObject(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function parseJson(value: string) {
const parsed = value.trim() ? JSON.parse(value) : {};
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error('元数据 JSON 必须是对象');
}
return parsed as Record<string, unknown>;
}
@@ -0,0 +1,497 @@
export type CapabilityEditorState = {
types: string[];
flags: Record<CapabilityFlagKey, boolean>;
limits: Record<CapabilityLimitKey, string>;
options: Record<CapabilityOptionKey, string>;
typeConfigs: Record<string, Record<string, unknown>>;
extra: Record<string, unknown>;
};
export type CapabilityFlagKey =
| 'stream'
| 'vision'
| 'reasoning'
| 'multimodal'
| 'supportTool'
| 'supportThinking'
| 'supportThinkingModeSwitch'
| 'supportStructuredOutput'
| 'supportWebSearch'
| 'inputMultipleImages'
| 'outputMultipleImages'
| 'supportBase64Input'
| 'supportUrlInput'
| 'outputAudio';
export type CapabilityLimitKey =
| 'maxContextTokens'
| 'maxInputTokens'
| 'maxOutputTokens'
| 'maxThinkingTokens'
| 'inputMaxImagesCount'
| 'outputMaxImagesCount'
| 'outputMaxSize'
| 'durationMin'
| 'durationMax';
export type CapabilityOptionKey = 'outputResolutions' | 'aspectRatios' | 'thinkingEfforts' | 'embeddingDimensions';
export type CapabilityConfigArea = 'text' | 'embedding' | 'image' | 'video' | 'audio' | 'digitalHuman' | 'model3d' | 'none';
export type PlatformModelTypeDefinition = {
key: string;
label: string;
group: string;
area: CapabilityConfigArea;
};
export const platformModelTypeDefinitions: PlatformModelTypeDefinition[] = [
{ key: 'text_generate', label: '文本生成', group: '文本', area: 'text' },
{ key: 'text_embedding', label: '文本向量', group: '文本', area: 'embedding' },
{ key: 'image_generate', label: '图像生成', group: '图像', area: 'image' },
{ key: 'image_edit', label: '图像编辑', group: '图像', area: 'image' },
{ key: 'image_analysis', label: '图像理解', group: '理解', area: 'text' },
{ key: 'video_generate', label: '视频生成', group: '视频', area: 'video' },
{ key: 'image_to_video', label: '图生视频', group: '视频', area: 'video' },
{ key: 'video_edit', label: '视频编辑', group: '视频', area: 'video' },
{ key: 'omni_video', label: '多模态视频', group: '视频', area: 'video' },
{ key: 'video_understanding', label: '视频理解', group: '理解', area: 'text' },
{ key: 'audio_understanding', label: '音频理解', group: '理解', area: 'text' },
{ key: 'omni', label: '全模态理解', group: '理解', area: 'text' },
{ key: 'tools_call', label: '工具调用', group: '文本', area: 'text' },
{ key: 'audio_generate', label: '音频生成', group: '音频', area: 'audio' },
{ key: 'text_to_speech', label: '语音合成', group: '音频', area: 'audio' },
{ key: 'speech_to_text', label: '语音转写', group: '音频', area: 'audio' },
{ key: 'digital_human_generate', label: '数字人生成', group: '数字人', area: 'digitalHuman' },
{ key: 'text_to_model', label: '文生 3D', group: '3D', area: 'model3d' },
{ key: 'image_to_model', label: '图生 3D', group: '3D', area: 'model3d' },
{ key: 'multiview_to_model', label: '多视角 3D', group: '3D', area: 'model3d' },
{ key: 'mesh_edit', label: '3D 模型编辑', group: '3D', area: 'model3d' },
];
const modelTypeDefinitionMap = new Map(platformModelTypeDefinitions.map((item) => [item.key, item]));
const flagKeys: CapabilityFlagKey[] = [
'stream',
'vision',
'reasoning',
'multimodal',
'supportTool',
'supportThinking',
'supportThinkingModeSwitch',
'supportStructuredOutput',
'supportWebSearch',
'inputMultipleImages',
'outputMultipleImages',
'supportBase64Input',
'supportUrlInput',
'outputAudio',
];
const limitKeys: CapabilityLimitKey[] = [
'maxContextTokens',
'maxInputTokens',
'maxOutputTokens',
'maxThinkingTokens',
'inputMaxImagesCount',
'outputMaxImagesCount',
'outputMaxSize',
'durationMin',
'durationMax',
];
const optionKeys: CapabilityOptionKey[] = ['outputResolutions', 'aspectRatios', 'thinkingEfforts', 'embeddingDimensions'];
const managedRootKeys = new Set<string>([
'originalTypes',
'stream',
'vision',
'reasoning',
'multimodal',
'supportTool',
'supportThinking',
'supportThinkingModeSwitch',
'supportStructuredOutput',
'supportWebSearch',
'supportBase64Input',
'supportUrlInput',
'maxContextTokens',
'maxInputTokens',
'maxOutputTokens',
'maxThinkingTokens',
'thinkingEffortLevels',
]);
const managedNestedKeys = new Set<string>([
'supportTool',
'supportThinking',
'supportThinkingModeSwitch',
'supportStructuredOutput',
'supportWebSearch',
'thinkingEffortLevels',
'max_context_tokens',
'max_input_tokens',
'max_output_tokens',
'max_thinking_tokens',
'dimensions',
'support_base64_input',
'support_url_input',
'input_multiple_images',
'input_max_images_count',
'output_multiple_images',
'output_max_images_count',
'output_max_size',
'output_resolutions',
'aspect_ratio_allowed',
'aspect_ratio_range',
'input_aspect_ratio_range',
'duration_range',
'output_audio',
]);
export function createCapabilityEditorState(modelType = 'text_generate'): CapabilityEditorState {
return {
types: [modelType].filter(Boolean),
flags: Object.fromEntries(flagKeys.map((key) => [key, false])) as Record<CapabilityFlagKey, boolean>,
limits: Object.fromEntries(limitKeys.map((key) => [key, ''])) as Record<CapabilityLimitKey, string>,
options: Object.fromEntries(optionKeys.map((key) => [key, ''])) as Record<CapabilityOptionKey, string>,
typeConfigs: modelType ? { [modelType]: defaultCapabilityConfig(modelType) } : {},
extra: {},
};
}
export function modelTypeLabel(type: string) {
return modelTypeDefinitionMap.get(type)?.label ?? type;
}
export function modelTypeGroup(type: string) {
return modelTypeDefinitionMap.get(type)?.group ?? '其他';
}
export function capabilityAreas(types: string[]) {
const areas = new Set(types.map((type) => modelTypeDefinitionMap.get(type)?.area ?? inferArea(type)));
return {
hasText: areas.has('text'),
hasEmbedding: areas.has('embedding'),
hasImage: areas.has('image'),
hasVideo: areas.has('video'),
hasAudio: areas.has('audio'),
hasDigitalHuman: areas.has('digitalHuman'),
hasModel3d: areas.has('model3d'),
};
}
export function capabilitiesToForm(value?: Record<string, unknown>, modelType = 'text_generate'): CapabilityEditorState {
const state = createCapabilityEditorState(modelType);
const source = isRecord(value) ? value : {};
const sourceTypes = stringArray(source.originalTypes);
const nestedTypes = Object.entries(source)
.filter(([key, item]) => key !== 'originalTypes' && isRecord(item) && looksLikeCapabilityType(key))
.map(([key]) => key);
state.types = uniqueStrings([...sourceTypes, ...nestedTypes, modelType]);
state.typeConfigs = Object.fromEntries(state.types.map((type) => [type, readCapabilityConfig(source, type, modelType)]));
state.extra = Object.fromEntries(
Object.entries(source).filter(([key]) => !managedRootKeys.has(key) && !state.types.includes(key)),
);
state.flags.stream = boolFrom(source.stream);
state.flags.vision = boolFrom(source.vision);
state.flags.reasoning = boolFrom(source.reasoning);
state.flags.multimodal = boolFrom(source.multimodal);
state.flags.supportTool = boolFrom(source.supportTool ?? nestedValue(source, 'supportTool'));
state.flags.supportThinking = boolFrom(source.supportThinking ?? nestedValue(source, 'supportThinking'));
state.flags.supportThinkingModeSwitch = boolFrom(source.supportThinkingModeSwitch ?? nestedValue(source, 'supportThinkingModeSwitch'));
state.flags.supportStructuredOutput = boolFrom(source.supportStructuredOutput ?? nestedValue(source, 'supportStructuredOutput'));
state.flags.supportWebSearch = boolFrom(source.supportWebSearch ?? nestedValue(source, 'supportWebSearch'));
state.flags.supportBase64Input = boolFrom(source.supportBase64Input ?? nestedValue(source, 'support_base64_input'));
state.flags.supportUrlInput = boolFrom(source.supportUrlInput ?? nestedValue(source, 'support_url_input'));
state.flags.inputMultipleImages = nestedBool(source, 'input_multiple_images');
state.flags.outputMultipleImages = nestedBool(source, 'output_multiple_images');
state.flags.outputAudio = nestedBool(source, 'output_audio');
state.limits.maxContextTokens = stringValue(source.maxContextTokens ?? source.max_context_tokens ?? nestedValue(source, 'max_context_tokens'));
state.limits.maxInputTokens = stringValue(source.maxInputTokens ?? source.max_input_tokens ?? nestedValue(source, 'max_input_tokens'));
state.limits.maxOutputTokens = stringValue(source.maxOutputTokens ?? source.max_output_tokens ?? nestedValue(source, 'max_output_tokens'));
state.limits.maxThinkingTokens = stringValue(source.maxThinkingTokens ?? source.max_thinking_tokens ?? nestedValue(source, 'max_thinking_tokens'));
state.limits.inputMaxImagesCount = stringValue(nestedValue(source, 'input_max_images_count'));
state.limits.outputMaxImagesCount = stringValue(nestedValue(source, 'output_max_images_count'));
state.limits.outputMaxSize = stringValue(nestedValue(source, 'output_max_size'));
const durationRange = nestedValue(source, 'duration_range');
if (Array.isArray(durationRange)) {
state.limits.durationMin = stringValue(durationRange[0]);
state.limits.durationMax = stringValue(durationRange[1]);
}
state.options.outputResolutions = stringArray(nestedValue(source, 'output_resolutions')).join(', ');
state.options.aspectRatios = stringArray(nestedValue(source, 'aspect_ratio_allowed')).join(', ');
state.options.thinkingEfforts = stringArray(source.thinkingEffortLevels ?? nestedValue(source, 'thinkingEffortLevels')).join(', ');
state.options.embeddingDimensions = stringArray(nestedValue(source, 'dimensions')).join(', ');
return state;
}
export function syncPrimaryCapability(state: CapabilityEditorState, modelType: string): CapabilityEditorState {
if (!modelType || state.types.includes(modelType)) return state;
return addCapabilityType(state, modelType);
}
export function addCapabilityType(state: CapabilityEditorState, type: string): CapabilityEditorState {
if (!type) return state;
return {
...state,
types: uniqueStrings([...state.types, type]),
typeConfigs: {
...state.typeConfigs,
[type]: state.typeConfigs[type] ?? defaultCapabilityConfig(type),
},
};
}
export function removeCapabilityType(state: CapabilityEditorState, type: string): CapabilityEditorState {
const nextTypes = state.types.filter((item) => item !== type);
if (!nextTypes.length) return state;
const nextConfigs = { ...state.typeConfigs };
delete nextConfigs[type];
return { ...state, types: nextTypes, typeConfigs: nextConfigs };
}
export function updateCapabilityConfig(state: CapabilityEditorState, type: string, config: Record<string, unknown>): CapabilityEditorState {
return { ...state, typeConfigs: { ...state.typeConfigs, [type]: config } };
}
export function capabilitiesFromForm(state: CapabilityEditorState): Record<string, unknown> {
const output: Record<string, unknown> = { ...state.extra, originalTypes: uniqueStrings(state.types) };
state.types.forEach((type) => {
const config = normalizeCapabilityConfig(type, state.typeConfigs[type] ?? defaultCapabilityConfig(type));
if (Object.keys(config).length > 0) output[type] = config;
});
return output;
}
export function defaultCapabilityConfig(type: string): Record<string, unknown> {
if (isTextLike(type)) {
return {
supportTool: type === 'tools_call',
supportThinking: false,
supportThinkingModeSwitch: false,
supportStructuredOutput: false,
supportWebSearch: false,
};
}
if (type === 'text_embedding') return { dimensions: [] };
if (type === 'image_generate') {
return { output_resolutions: ['1K'], output_multiple_images: false };
}
if (type === 'image_edit') {
return { input_multiple_images: false, output_resolutions: ['1K'], output_multiple_images: false };
}
if (type === 'video_generate') {
return { output_resolutions: ['720p'], duration_range: [5, 10], output_audio: false };
}
if (type === 'image_to_video') {
return {
output_resolutions: ['720p'],
input_first_frame: true,
input_first_last_frame: false,
input_last_frame: false,
input_reference_generate_single: false,
input_reference_generate_multiple: false,
support_video_effect_template: false,
output_audio: false,
aspect_ratio_allowed: [],
};
}
if (type === 'video_edit') {
return { input_video_required: true, output_resolutions: ['720p'], duration_range: [5, 10], output_audio: false };
}
if (type === 'omni_video') {
return {
supported_modes: ['text_to_video'],
output_resolutions: ['720p'],
aspect_ratio_allowed: ['16:9'],
duration_options: [5],
output_audio: false,
max_videos: 0,
max_images: 0,
max_elements: 0,
};
}
if (type === 'text_to_model' || type === 'image_to_model' || type === 'multiview_to_model') {
return { support_texture: false, support_part_generation: false };
}
return {};
}
function cleanNestedConfig(value: Record<string, unknown>) {
return Object.fromEntries(Object.entries(value).filter(([key, item]) => !managedNestedKeys.has(key) || shouldKeepNestedValue(key, item)));
}
function readCapabilityConfig(source: Record<string, unknown>, type: string, modelType: string) {
const nested = isRecord(source[type]) ? source[type] : {};
const root = type === modelType ? rootCompatibilityConfig(source) : {};
const hasSourceConfig = Object.keys(root).length > 0 || Object.keys(nested).length > 0;
const base = hasSourceConfig ? {} : defaultCapabilityConfig(type);
return normalizeCapabilityConfig(type, { ...base, ...root, ...nested });
}
function rootCompatibilityConfig(source: Record<string, unknown>) {
const config: Record<string, unknown> = {};
[
'stream',
'vision',
'reasoning',
'multimodal',
'supportTool',
'supportThinking',
'supportThinkingModeSwitch',
'supportStructuredOutput',
'supportWebSearch',
'supportBase64Input',
'supportUrlInput',
'maxContextTokens',
'maxInputTokens',
'maxOutputTokens',
'maxThinkingTokens',
'thinkingEffortLevels',
].forEach((key) => {
if (key in source) config[toCapabilityKey(key)] = source[key];
});
return config;
}
function toCapabilityKey(key: string) {
const map: Record<string, string> = {
supportBase64Input: 'support_base64_input',
supportUrlInput: 'support_url_input',
maxContextTokens: 'max_context_tokens',
maxInputTokens: 'max_input_tokens',
maxOutputTokens: 'max_output_tokens',
maxThinkingTokens: 'max_thinking_tokens',
};
return map[key] ?? key;
}
function normalizeCapabilityConfig(type: string, config: Record<string, unknown>) {
return Object.fromEntries(Object.entries(config).filter(([key, value]) => shouldKeepConfigValue(type, key, value)));
}
function shouldKeepConfigValue(type: string, key: string, value: unknown) {
if (value === undefined || value === null || value === '') return false;
if (Array.isArray(value)) return value.length > 0 || defaultArrayKeys(type).has(key);
if (isRecord(value)) return Object.keys(value).length > 0;
return true;
}
function defaultArrayKeys(type: string) {
if (type === 'text_embedding') return new Set(['dimensions']);
if (type === 'image_to_video' || type === 'omni_video') return new Set(['aspect_ratio_allowed']);
return new Set<string>();
}
function shouldKeepNestedValue(key: string, value: unknown) {
if (key === 'output_resolutions' || key === 'aspect_ratio_allowed' || key === 'aspect_ratio_range' || key === 'input_aspect_ratio_range' || key === 'duration_range') {
return !Array.isArray(value);
}
if (key === 'thinkingEffortLevels' || key === 'dimensions') {
return !Array.isArray(value);
}
if (key.endsWith('_count') || key === 'output_max_size' || key.endsWith('_tokens')) {
return typeof value !== 'number' && typeof value !== 'string';
}
return typeof value !== 'boolean' && value !== 'true' && value !== 'false';
}
function nestedValue(source: Record<string, unknown>, key: string) {
for (const type of stringArray(source.originalTypes)) {
const config = isRecord(source[type]) ? source[type] : undefined;
if (config && key in config) return config[key];
}
for (const value of Object.values(source)) {
if (isRecord(value) && key in value) return value[key];
}
return undefined;
}
function nestedBool(source: Record<string, unknown>, key: string) {
return boolFrom(nestedValue(source, key));
}
function setIfTruthy(target: Record<string, unknown>, key: string, value: boolean) {
if (value) target[key] = true;
}
function setBoolean(target: Record<string, unknown>, key: string, value: boolean) {
target[key] = value;
}
function setNumberIfPresent(target: Record<string, unknown>, key: string, value: string) {
const parsed = Number(value);
if (value.trim() && Number.isFinite(parsed)) target[key] = parsed;
}
function setArrayIfPresent(target: Record<string, unknown>, key: string, value: string) {
const items = value.split(',').map((item) => item.trim()).filter(Boolean);
if (items.length) target[key] = items;
}
function setNumberArrayIfPresent(target: Record<string, unknown>, key: string, value: string) {
const items = value.split(',').map((item) => Number(item.trim())).filter(Number.isFinite);
if (items.length) target[key] = items;
}
function durationRange(min: string, max: string) {
const parsedMin = Number(min);
const parsedMax = Number(max);
if (!min.trim() && !max.trim()) return undefined;
return [Number.isFinite(parsedMin) ? parsedMin : 0, Number.isFinite(parsedMax) ? parsedMax : parsedMin || 0];
}
function stringValue(value: unknown) {
return value === undefined || value === null ? '' : String(value);
}
function stringArray(value: unknown) {
if (!Array.isArray(value)) return [];
return value.map(String).filter(Boolean);
}
function uniqueStrings(values: string[]) {
return Array.from(new Set(values.map((item) => item.trim()).filter(Boolean)));
}
function boolFrom(value: unknown) {
return value === true || value === 'true';
}
function looksLikeCapabilityType(key: string) {
return modelTypeDefinitionMap.has(key) || key.includes('_') || ['chat', 'image', 'video', 'audio', 'embedding', 'music', 'digital_human', 'model_3d'].includes(key);
}
function inferArea(type: string): CapabilityConfigArea {
if (isTextLike(type)) return 'text';
if (type === 'text_embedding') return 'embedding';
if (isImageLike(type)) return 'image';
if (isVideoLike(type)) return 'video';
if (type.includes('audio') || type.includes('speech')) return 'audio';
if (type.includes('digital_human')) return 'digitalHuman';
if (type.includes('model') || type.includes('mesh')) return 'model3d';
return 'none';
}
function isTextLike(type: string) {
return ['text_generate', 'image_analysis', 'video_understanding', 'audio_understanding', 'omni', 'tools_call'].includes(type);
}
function isImageLike(type: string) {
return type.includes('image') || type === 'image';
}
function isVideoLike(type: string) {
return type.includes('video') || type === 'video';
}
function isVisualMedia(type: string) {
return isImageLike(type) || isVideoLike(type);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && !Array.isArray(value) && typeof value === 'object';
}
+350
View File
@@ -0,0 +1,350 @@
import type { BaseModelCatalogItem } from '@easyai-ai-gateway/contracts';
import type { PlatformCreateInput, PlatformModelBindingInput } from '../../types';
export const authTypes = [
{ value: 'APIKey', label: 'API Key', description: 'apiKey 字段,兼容 OpenAI / Gemini' },
{ value: 'Token', label: 'Token', description: 'token 字段,适合 Bearer Token 类平台' },
{ value: 'AccessKey-SecretKey', label: 'AccessKey + SecretKey', description: 'accessKey / secretKey 双字段' },
{ value: 'none', label: '无授权', description: '仅用于内网或测试模式' },
];
export interface PlatformWizardForm {
provider: string;
platformKey: string;
name: string;
internalName: string;
baseUrl: string;
authType: string;
credentialsPreview: Record<string, unknown>;
apiKey: string;
token: string;
accessKey: string;
secretKey: string;
pricingRuleSetId: string;
defaultDiscountFactor: string;
priority: string;
retryEnabled: boolean;
retryMaxAttempts: string;
rpmLimit: string;
rpsLimit: string;
tpmLimit: string;
concurrencyLimit: string;
testMode: boolean;
supportBase64Input: boolean;
supportUrlInput: boolean;
modelDiscountFactor: string;
modelDiscountFactors: Record<string, string>;
modelOverrideRetry: boolean;
modelRetryEnabled: boolean;
modelRetryMaxAttempts: string;
modelOverrideRateLimit: boolean;
modelRpmLimit: string;
modelRpsLimit: string;
modelTpmLimit: string;
modelConcurrencyLimit: string;
selectionMode: 'all' | 'partial';
selectedModelIds: string[];
}
export interface ProviderConnectionDefaults {
defaultAuthType?: string;
defaultBaseUrl?: string;
}
export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnectionDefaults): PlatformWizardForm {
return {
provider,
platformKey: '',
name: '',
internalName: '',
baseUrl: defaults?.defaultBaseUrl ?? '',
authType: defaults?.defaultAuthType ?? 'APIKey',
credentialsPreview: {},
apiKey: '',
token: '',
accessKey: '',
secretKey: '',
pricingRuleSetId: '',
defaultDiscountFactor: '1',
priority: '100',
retryEnabled: true,
retryMaxAttempts: '2',
rpmLimit: '',
rpsLimit: '',
tpmLimit: '',
concurrencyLimit: '',
testMode: false,
supportBase64Input: true,
supportUrlInput: true,
modelDiscountFactor: '',
modelDiscountFactors: {},
modelOverrideRetry: false,
modelRetryEnabled: true,
modelRetryMaxAttempts: '2',
modelOverrideRateLimit: false,
modelRpmLimit: '',
modelRpsLimit: '',
modelTpmLimit: '',
modelConcurrencyLimit: '',
selectionMode: 'partial',
selectedModelIds: [],
};
}
export function applyProviderDefaults(form: PlatformWizardForm, provider: string, defaults?: ProviderConnectionDefaults): PlatformWizardForm {
return {
...form,
provider,
baseUrl: defaults?.defaultBaseUrl ?? '',
authType: defaults?.defaultAuthType ?? 'APIKey',
selectedModelIds: [],
modelDiscountFactors: {},
};
}
export function modelsForProvider(models: BaseModelCatalogItem[], provider: string) {
return models.filter((item) => item.providerKey === provider && item.status !== 'hidden');
}
export function selectedModelsForForm(models: BaseModelCatalogItem[], form: PlatformWizardForm) {
const visibleModels = models.filter((item) => item.status !== 'hidden');
if (form.selectionMode === 'all') {
return visibleModels;
}
const selectedIds = new Set(form.selectedModelIds);
return visibleModels.filter((item) => selectedIds.has(item.id));
}
export function platformPayload(form: PlatformWizardForm, options: { preserveEmptyCredentials?: boolean } = {}): PlatformCreateInput {
const credentials = credentialsPayload(form, options);
return {
provider: form.provider,
platformKey: optionalString(form.platformKey),
name: form.name.trim(),
internalName: optionalString(form.internalName),
baseUrl: form.baseUrl.trim(),
authType: form.authType,
credentials,
config: {
testMode: form.testMode,
supportBase64Input: form.supportBase64Input,
supportUrlInput: form.supportUrlInput,
source: 'gateway-admin',
},
retryPolicy: {
enabled: form.retryEnabled,
maxAttempts: form.retryEnabled ? positiveInt(form.retryMaxAttempts, 2) : 1,
},
rateLimitPolicy: rateLimitPolicyPayload(form),
defaultPricingMode: 'inherit_discount',
defaultDiscountFactor: positiveNumber(form.defaultDiscountFactor, 1),
pricingRuleSetId: optionalString(form.pricingRuleSetId),
priority: positiveInt(form.priority, 100),
};
}
export function platformModelPayloads(models: BaseModelCatalogItem[], form: PlatformWizardForm): PlatformModelBindingInput[] {
return selectedModelsForForm(models, form).map((model) => ({
baseModelId: model.id,
canonicalModelKey: model.canonicalModelKey,
modelName: model.providerModelName,
modelAlias: stableModelAlias(model),
modelType: primaryBaseModelType(model),
displayName: stableModelAlias(model) || model.providerModelName,
pricingMode: 'inherit_discount',
discountFactor: optionalPositiveNumber(form.modelDiscountFactors[model.id]) ?? optionalPositiveNumber(form.modelDiscountFactor),
retryPolicy: form.modelOverrideRetry
? { enabled: form.modelRetryEnabled, maxAttempts: form.modelRetryEnabled ? positiveInt(form.modelRetryMaxAttempts, 2) : 1 }
: undefined,
rateLimitPolicy: form.modelOverrideRateLimit ? rateLimitPolicyPayload({
rpmLimit: form.modelRpmLimit,
rpsLimit: form.modelRpsLimit,
tpmLimit: form.modelTpmLimit,
concurrencyLimit: form.modelConcurrencyLimit,
}) : undefined,
runtimePolicyOverride: platformModelRuntimeOverride(form),
}));
}
export function stableModelAlias(model: BaseModelCatalogItem) {
return stripProviderPrefix(
readString(model.modelAlias) ||
readString(model.displayName) ||
readString(model.metadata?.alias) ||
readString(readRecord(model.metadata?.rawModel)?.alias) ||
model.providerModelName ||
model.canonicalModelKey,
model.providerKey,
model.canonicalModelKey,
);
}
export function baseModelTypes(model: BaseModelCatalogItem) {
if (Array.isArray(model.modelType)) return model.modelType.map(String).filter(Boolean);
const values = model.metadata?.originalTypes ?? model.capabilities?.originalTypes;
if (Array.isArray(values)) return values.map(String).filter(Boolean);
return [];
}
export function baseModelTypeText(model: BaseModelCatalogItem) {
return baseModelTypes(model).join(', ');
}
export function primaryBaseModelType(model: BaseModelCatalogItem) {
return baseModelTypes(model)[0] ?? 'text_generate';
}
function stripProviderPrefix(value: string, providerKey: string, canonicalModelKey: string) {
const trimmed = value.trim();
if (!trimmed) return trimmed;
const prefix = `${providerKey}:`;
if (providerKey && trimmed.startsWith(prefix)) return trimmed.slice(prefix.length);
if (trimmed === canonicalModelKey) {
const [, alias] = trimmed.split(/:(.*)/s);
return alias || trimmed;
}
return trimmed;
}
function readRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
}
function readString(value: unknown) {
return typeof value === 'string' ? value.trim() : '';
}
function platformModelRuntimeOverride(form: PlatformWizardForm) {
const override: Record<string, unknown> = {};
if (form.modelOverrideRetry) {
override.retryPolicy = {
enabled: form.modelRetryEnabled,
maxAttempts: form.modelRetryEnabled ? positiveInt(form.modelRetryMaxAttempts, 2) : 1,
};
}
if (form.modelOverrideRateLimit) {
override.rateLimitPolicy = rateLimitPolicyPayload({
rpmLimit: form.modelRpmLimit,
rpsLimit: form.modelRpsLimit,
tpmLimit: form.modelTpmLimit,
concurrencyLimit: form.modelConcurrencyLimit,
});
}
return Object.keys(override).length ? override : undefined;
}
export function validatePlatformForm(form: PlatformWizardForm, selectedCount: number, options: { allowEmptyCredentials?: boolean } = {}): string {
if (!form.provider.trim()) return '请选择模型厂商。';
if (!form.name.trim()) return '请填写平台名称。';
if (!form.baseUrl.trim()) return '请填写 Base URL。';
if (options.allowEmptyCredentials && !hasCredentialInput(form)) {
if (selectedCount === 0) return '请至少添加一个模型。';
return '';
}
if (form.authType === 'APIKey' && !form.apiKey.trim() && !form.testMode) return '请填写 API Key,或开启测试模式。';
if (form.authType === 'Token' && !form.token.trim() && !form.testMode) return '请填写 Token,或开启测试模式。';
if (form.authType === 'AccessKey-SecretKey' && (!form.accessKey.trim() || !form.secretKey.trim()) && !form.testMode) {
return '请填写 AccessKey 和 SecretKey,或开启测试模式。';
}
if (selectedCount === 0) return '请至少添加一个模型。';
return '';
}
export function providerLabel(provider: string) {
return provider || 'provider';
}
function credentialsPayload(form: PlatformWizardForm, options: { preserveEmptyCredentials?: boolean } = {}) {
if (form.authType === 'APIKey') {
return credentialPayloadForFields(form, options, [{ key: 'apiKey', value: form.apiKey, previewKeys: ['apiKey', 'api_key', 'key'] }]);
}
if (form.authType === 'Token') {
return credentialPayloadForFields(form, options, [{ key: 'token', value: form.token, previewKeys: ['token'] }]);
}
if (form.authType === 'AccessKey-SecretKey') {
return credentialPayloadForFields(form, options, [
{ key: 'accessKey', value: form.accessKey, previewKeys: ['accessKey', 'access_key'] },
{ key: 'secretKey', value: form.secretKey, previewKeys: ['secretKey', 'secret_key'] },
]);
}
return {};
}
function credentialPayloadForFields(
form: PlatformWizardForm,
options: { preserveEmptyCredentials?: boolean },
fields: Array<{ key: string; previewKeys: string[]; value: string }>,
) {
const entries = fields.map((field) => ({
key: field.key,
preview: credentialPreviewValue(form.credentialsPreview, ...field.previewKeys),
value: field.value.trim(),
}));
const allUnchanged = entries.every((entry) => entry.preview && entry.value === entry.preview);
if (options.preserveEmptyCredentials && allUnchanged) return undefined;
const allEmpty = entries.every((entry) => !entry.value);
if (allEmpty) return {};
const payloadEntries: Array<[string, string | null]> = [];
for (const entry of entries) {
if (entry.preview && entry.value === entry.preview) continue;
payloadEntries.push([entry.key, entry.value || null]);
}
return Object.fromEntries(payloadEntries);
}
function hasCredentialInput(form: PlatformWizardForm) {
if (form.authType === 'APIKey') return Boolean(form.apiKey.trim());
if (form.authType === 'Token') return Boolean(form.token.trim());
if (form.authType === 'AccessKey-SecretKey') return Boolean(form.accessKey.trim() || form.secretKey.trim());
return true;
}
function credentialPreviewValue(preview: Record<string, unknown> | undefined, ...keys: string[]) {
if (!preview) return '';
for (const key of keys) {
const value = preview[key];
if (typeof value === 'string' && value.trim()) return value;
}
return '';
}
function rateLimitPolicyPayload(form: Pick<PlatformWizardForm, 'rpmLimit' | 'rpsLimit' | 'tpmLimit' | 'concurrencyLimit'>) {
const rules = [
limitRule('rpm', form.rpmLimit),
limitRule('rps', form.rpsLimit, 1),
limitRule('tpm_total', form.tpmLimit),
limitRule('concurrent', form.concurrencyLimit),
].filter((rule): rule is NonNullable<ReturnType<typeof limitRule>> => Boolean(rule));
return rules.length ? { rules } : {};
}
function limitRule(metric: string, value: string, windowSeconds = 60) {
const limit = positiveNumber(value, 0);
if (!limit) return undefined;
return {
metric,
limit,
windowSeconds,
leaseTtlSeconds: metric === 'concurrent' ? 120 : 70,
};
}
function optionalString(value: string) {
const trimmed = value.trim();
return trimmed || undefined;
}
function positiveNumber(value: string, fallback: number) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function optionalPositiveNumber(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
}
function positiveInt(value: string, fallback: number) {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}