feat: improve model catalog aggregation

This commit is contained in:
2026-05-11 17:44:57 +08:00
parent ec87816c95
commit 0431cb8157
41 changed files with 4745 additions and 550 deletions
+60 -9
View File
@@ -12,6 +12,7 @@ import type {
GatewayTenant,
GatewayUser,
IntegrationPlatform,
ModelCatalogResponse,
PlatformModel,
PricingRule,
PricingRuleSet,
@@ -42,6 +43,7 @@ import {
listApiKeys,
listBaseModels,
listCatalogProviders,
listModelCatalog,
listModels,
listPlayableApiKeys,
listPlayableModels,
@@ -87,6 +89,8 @@ import {
pathForPage,
pathForPlaygroundMode,
pathForWorkspaceSection,
pathForWorkspaceTaskQuery,
workspaceTaskQueryKey,
type AppRouteState,
} from './routing';
import type {
@@ -103,6 +107,7 @@ import type {
PlatformWithModelsInput,
RegisterForm,
TaskForm,
WorkspaceTaskQuery,
WorkspaceSection,
} from './types';
@@ -111,6 +116,7 @@ type DataKey =
| 'publicCatalog'
| 'playgroundApiKeys'
| 'playgroundModels'
| 'modelCatalog'
| 'platforms'
| 'models'
| 'providers'
@@ -131,6 +137,7 @@ export function App() {
const [activePage, setActivePage] = useState<PageKey>(initialRoute.activePage);
const [adminSection, setAdminSection] = useState<AdminSection>(initialRoute.adminSection);
const [workspaceSection, setWorkspaceSection] = useState<WorkspaceSection>(initialRoute.workspaceSection);
const [workspaceTaskQuery, setWorkspaceTaskQuery] = useState<WorkspaceTaskQuery>(initialRoute.workspaceTaskQuery);
const [apiDocSection, setApiDocSection] = useState<ApiDocSection>(initialRoute.apiDocSection);
const [playgroundMode, setPlaygroundMode] = useState<PlaygroundMode>(initialRoute.playgroundMode);
const [token, setToken] = useState(readStoredAccessToken);
@@ -141,6 +148,11 @@ export function App() {
const [health, setHealth] = useState<HealthResponse | null>(null);
const [platforms, setPlatforms] = useState<IntegrationPlatform[]>([]);
const [models, setModels] = useState<PlatformModel[]>([]);
const [modelCatalog, setModelCatalog] = useState<ModelCatalogResponse>({
items: [],
filters: { capabilities: [], providers: [] },
summary: { modelCount: 0, sourceCount: 0 },
});
const [playgroundModels, setPlaygroundModels] = useState<PlatformModel[]>([]);
const [providers, setProviders] = useState<CatalogProvider[]>([]);
const [baseModels, setBaseModels] = useState<BaseModelCatalogItem[]>([]);
@@ -160,12 +172,15 @@ export function App() {
const [taskForm, setTaskForm] = useState<TaskForm>({ kind: 'chat.completions', model: 'gpt-4o-mini', prompt: '用一句话确认 AI Gateway simulation 链路正常。' });
const [taskResult, setTaskResult] = useState<GatewayTask | null>(null);
const [tasks, setTasks] = useState<GatewayTask[]>([]);
const [taskTotal, setTaskTotal] = useState(0);
const [coreState, setCoreState] = useState<LoadState>('idle');
const [coreMessage, setCoreMessage] = useState('');
const [state, setState] = useState<LoadState>('idle');
const [error, setError] = useState('');
const loadedDataKeysRef = useRef(new Set<DataKey>());
const loadingDataKeysRef = useRef(new Set<DataKey>());
const loadedTaskQueryKeyRef = useRef('');
const currentTaskQueryKeyRef = useRef('');
const { removeBaseModel, removeProvider, resetAllBaseModelsToDefault, resetBaseModelToDefault, saveBaseModel, saveProvider } = useCatalogOperations({
setBaseModels,
setCoreMessage,
@@ -185,13 +200,15 @@ export function App() {
setRuntimePolicySets,
token,
});
const taskListRequestKey = workspaceTaskQueryKey(workspaceTaskQuery);
currentTaskQueryKeyRef.current = taskListRequestKey;
useEffect(() => {
void ensureData(['health']);
}, []);
useEffect(() => {
void ensureRouteData(token);
}, [activePage, adminSection, workspaceSection, token]);
}, [activePage, adminSection, taskListRequestKey, workspaceSection, token]);
useEffect(() => {
function handlePopState() {
applyRoute(parseAppRoute());
@@ -223,6 +240,7 @@ export function App() {
accessRules,
apiKeys,
baseModels,
modelCatalog,
models,
platforms,
pricingRules,
@@ -235,7 +253,7 @@ export function App() {
tenants,
userGroups,
users,
}), [accessRules, apiKeys, baseModels, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
}), [accessRules, apiKeys, baseModels, modelCatalog, models, platforms, pricingRuleSets, pricingRules, providers, rateLimitWindows, runtimePolicySets, taskResult, tasks, tenants, userGroups, users]);
async function refresh(nextToken = token) {
await ensureRouteData(nextToken, true);
@@ -246,6 +264,10 @@ export function App() {
}
async function ensureRouteData(nextToken = token, force = false) {
if (activePage === 'workspace' && workspaceSection === 'tasks' && loadedTaskQueryKeyRef.current !== taskListRequestKey) {
loadedDataKeysRef.current.delete('tasks');
loadingDataKeysRef.current.delete('tasks');
}
await ensureData(dataKeysForRoute(activePage, adminSection, workspaceSection, Boolean(nextToken)), nextToken, force);
}
@@ -294,6 +316,9 @@ export function App() {
case 'models':
setModels((await listModels(nextToken)).items);
return;
case 'modelCatalog':
setModelCatalog(await listModelCatalog(nextToken));
return;
case 'playgroundModels':
setPlaygroundModels((await listPlayableModels(nextToken)).items);
return;
@@ -332,7 +357,14 @@ export function App() {
setUserGroups((await listUserGroups(nextToken)).items);
return;
case 'tasks':
setTasks((await listTasks(nextToken)).items);
{
const requestKey = taskListRequestKey;
const response = await listTasks(nextToken, workspaceTaskQuery);
if (requestKey !== currentTaskQueryKeyRef.current) return;
setTasks(response.items);
setTaskTotal(response.total ?? response.items.length);
loadedTaskQueryKeyRef.current = requestKey;
}
return;
case 'accessRules':
setAccessRules((await (activePage === 'workspace' && workspaceSection === 'apiKeys'
@@ -420,6 +452,7 @@ export function App() {
const modelsResponse = await replacePlatformModels(token, platform.id, modelBindings);
setPlatforms((current) => [platformForState, ...current.filter((item) => item.id !== platform.id)]);
setModels((current) => [...current.filter((model) => model.platformId !== platform.id), ...modelsResponse.items]);
invalidateDataKeys('modelCatalog');
setCoreState('ready');
setCoreMessage(input.platformId
? `平台已更新,当前绑定 ${input.models.length} 个模型。`
@@ -438,6 +471,7 @@ export function App() {
await deletePlatform(token, platformId);
setPlatforms((current) => current.filter((item) => item.id !== platformId));
setModels((current) => current.filter((item) => item.platformId !== platformId));
invalidateDataKeys('modelCatalog');
setCoreState('ready');
setCoreMessage('平台已删除。');
} catch (err) {
@@ -514,6 +548,7 @@ export function App() {
try {
const item = groupId ? await updateUserGroup(token, groupId, input) : await createUserGroup(token, input);
setUserGroups((current) => [item, ...current.filter((group) => group.id !== item.id)]);
invalidateDataKeys('modelCatalog');
setCoreState('ready');
setCoreMessage(groupId ? '用户组已更新。' : '用户组已创建。');
} catch (err) {
@@ -531,6 +566,7 @@ export function App() {
setUserGroups((current) => current.filter((group) => group.id !== groupId));
setTenants((current) => current.map((tenant) => tenant.defaultUserGroupId === groupId ? { ...tenant, defaultUserGroupId: undefined } : tenant));
setUsers((current) => current.map((user) => user.defaultUserGroupId === groupId ? { ...user, defaultUserGroupId: undefined } : user));
invalidateDataKeys('modelCatalog');
invalidateDataKeys('playgroundModels');
setCoreState('ready');
setCoreMessage('用户组已删除。');
@@ -569,7 +605,7 @@ export function App() {
try {
const item = ruleId ? await updateAccessRule(token, ruleId, input) : await createAccessRule(token, input);
setAccessRules((current) => [item, ...current.filter((rule) => rule.id !== item.id)]);
invalidateDataKeys('playgroundModels');
invalidateDataKeys('playgroundModels', 'modelCatalog');
setCoreState('ready');
setCoreMessage(ruleId ? '访问权限规则已更新。' : '访问权限规则已创建。');
} catch (err) {
@@ -585,7 +621,7 @@ export function App() {
try {
await deleteAccessRule(token, ruleId);
setAccessRules((current) => current.filter((rule) => rule.id !== ruleId));
invalidateDataKeys('playgroundModels');
invalidateDataKeys('playgroundModels', 'modelCatalog');
setCoreState('ready');
setCoreMessage('访问权限规则已删除。');
} catch (err) {
@@ -601,7 +637,7 @@ export function App() {
try {
const response = await batchAccessRules(token, input);
setAccessRules(response.items);
invalidateDataKeys('playgroundModels');
invalidateDataKeys('playgroundModels', 'modelCatalog');
setCoreState('ready');
setCoreMessage('访问权限已更新。');
} catch (err) {
@@ -653,6 +689,7 @@ export function App() {
setState('idle');
setPlatforms([]);
setModels([]);
setModelCatalog({ items: [], filters: { capabilities: [], providers: [] }, summary: { modelCount: 0, sourceCount: 0 } });
setPlaygroundModels([]);
setProviders([]);
setBaseModels([]);
@@ -670,6 +707,7 @@ export function App() {
setSelectedPlaygroundApiKeyId('');
setTaskResult(null);
setTasks([]);
setTaskTotal(0);
setCoreMessage('');
navigatePath('/');
}
@@ -680,7 +718,7 @@ export function App() {
}
function currentRouteState(): AppRouteState {
return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection };
return { activePage, adminSection, apiDocSection, playgroundMode, workspaceSection, workspaceTaskQuery };
}
function applyRoute(route: AppRouteState) {
@@ -689,10 +727,11 @@ export function App() {
setApiDocSection(route.apiDocSection);
setPlaygroundMode(route.playgroundMode);
setWorkspaceSection(route.workspaceSection);
setWorkspaceTaskQuery(route.workspaceTaskQuery);
}
function navigatePath(path: string) {
if (window.location.pathname !== path) {
if (`${window.location.pathname}${window.location.search}` !== path) {
window.history.pushState(null, '', path);
}
applyRoute(parseAppRoute(path));
@@ -710,6 +749,10 @@ export function App() {
navigatePath(pathForWorkspaceSection(section));
}
function navigateWorkspaceTaskQuery(query: WorkspaceTaskQuery) {
navigatePath(pathForWorkspaceTaskQuery(query));
}
function navigateApiDocSection(section: ApiDocSection) {
navigatePath(pathForApiDocSection(section));
}
@@ -768,11 +811,14 @@ export function App() {
message={coreMessage}
section={workspaceSection}
state={coreState}
taskQuery={workspaceTaskQuery}
taskTotal={taskTotal}
onBatchAccessRules={batchSaveAPIKeyAccessRules}
onDeleteApiKey={removeAPIKey}
onApiKeyFormChange={setApiKeyForm}
onSectionChange={navigateWorkspaceSection}
onSubmitApiKey={submitAPIKey}
onTaskQueryChange={navigateWorkspaceTaskQuery}
onUseApiKeyForPlayground={useApiKeyForPlayground}
/>
) : (
@@ -877,6 +923,7 @@ function mergeExistingPlatformModelInput(input: PlatformModelBindingInput, curre
if (!existing) return input;
return {
...input,
providerModelName: input.providerModelName ?? existing.providerModelName,
discountFactor: (input.discountFactor ?? existing.discountFactor) || undefined,
pricingRuleSetId: input.pricingRuleSetId ?? existing.pricingRuleSetId,
rateLimitPolicy: input.rateLimitPolicy ?? existing.rateLimitPolicy,
@@ -933,7 +980,11 @@ function dataKeysForRoute(
isAuthenticated: boolean,
): DataKey[] {
if (activePage === 'playground') return isAuthenticated ? ['playgroundModels', 'playgroundApiKeys'] : [];
if (activePage === 'models') return ['publicCatalog'];
if (activePage === 'models') {
return isAuthenticated
? ['modelCatalog']
: ['publicCatalog'];
}
if (activePage === 'home' || activePage === 'docs') return [];
if (!isAuthenticated) return [];
+16 -3
View File
@@ -16,6 +16,7 @@ import type {
GatewayUserUpsertRequest,
IntegrationPlatform,
ListResponse,
ModelCatalogResponse,
PlatformModel,
PlayableGatewayApiKey,
PricingRule,
@@ -27,7 +28,7 @@ import type {
UserGroup,
UserGroupUpsertRequest,
} from '@easyai-ai-gateway/contracts';
import type { PlatformCreateInput, PlatformModelBindingInput } from './types';
import type { PlatformCreateInput, PlatformModelBindingInput, WorkspaceTaskQuery } from './types';
const API_BASE = import.meta.env.VITE_GATEWAY_API_BASE_URL ?? 'http://localhost:8088';
@@ -76,6 +77,10 @@ export async function listPlayableModels(token: string): Promise<ListResponse<Pl
return request<ListResponse<PlatformModel>>('/api/v1/models', { token });
}
export async function listModelCatalog(token: string): Promise<ModelCatalogResponse> {
return request<ModelCatalogResponse>('/api/v1/model-catalog', { token });
}
export async function listPublicCatalogProviders(): Promise<ListResponse<CatalogProvider>> {
return request<ListResponse<CatalogProvider>>('/api/v1/public/catalog/providers', { auth: false });
}
@@ -582,8 +587,16 @@ export async function getTask(token: string, taskId: string): Promise<GatewayTas
return request<GatewayTask>(`/api/v1/tasks/${taskId}`, { token });
}
export async function listTasks(token: string, limit = 50): Promise<ListResponse<GatewayTask>> {
return request<ListResponse<GatewayTask>>(`/api/v1/tasks?limit=${encodeURIComponent(String(limit))}`, { token });
export async function listTasks(token: string, query: WorkspaceTaskQuery): Promise<ListResponse<GatewayTask>> {
const search = new URLSearchParams({
page: String(query.page),
pageSize: String(query.pageSize),
});
if (query.query) search.set('q', query.query);
if (query.modelType) search.set('modelType', query.modelType);
if (query.createdFrom) search.set('createdFrom', query.createdFrom);
if (query.createdTo) search.set('createdTo', query.createdTo);
return request<ListResponse<GatewayTask>>(`/api/v1/tasks?${search.toString()}`, { token });
}
export function resolveApiAssetUrl(src: string) {
+2
View File
@@ -7,6 +7,7 @@ import type {
GatewayTenant,
GatewayUser,
IntegrationPlatform,
ModelCatalogResponse,
PlatformModel,
PricingRule,
PricingRuleSet,
@@ -19,6 +20,7 @@ export interface ConsoleData {
accessRules: GatewayAccessRule[];
apiKeys: GatewayApiKey[];
baseModels: BaseModelCatalogItem[];
modelCatalog: ModelCatalogResponse;
models: PlatformModel[];
platforms: IntegrationPlatform[];
pricingRules: PricingRule[];
@@ -1,5 +1,11 @@
import { useMemo, useState } from 'react';
import { format } from 'date-fns';
import ConfigProvider from 'antd/es/config-provider';
import DatePicker from 'antd/es/date-picker';
import zhCN from 'antd/es/locale/zh_CN';
import dayjs, { type Dayjs } from 'dayjs';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import 'dayjs/locale/zh-cn';
import { CalendarIcon, X } from 'lucide-react';
import { Button } from './button';
import { Calendar } from './calendar';
@@ -7,7 +13,11 @@ import { Input } from './input';
import { Label } from './label';
import { Popover, PopoverContent, PopoverTrigger } from './popover';
dayjs.extend(customParseFormat);
dayjs.locale('zh-cn');
export function DateTimePicker(props: {
clearLabel?: string;
disabled?: boolean;
placeholder?: string;
value: string;
@@ -54,7 +64,7 @@ export function DateTimePicker(props: {
<Input type="time" value={timeValue} onChange={(event) => updateTime(event.target.value)} />
</Label>
<Button type="button" variant="ghost" size="icon" title="清除有效期" disabled={!props.value} onClick={() => props.onChange('')}>
<Button type="button" variant="ghost" size="icon" title={props.clearLabel ?? '清除有效期'} disabled={!props.value} onClick={() => props.onChange('')}>
<X size={14} />
</Button>
</div>
@@ -63,9 +73,68 @@ export function DateTimePicker(props: {
);
}
export function DateTimeRangePicker(props: {
disabled?: boolean;
from: string;
fromPlaceholder?: string;
to: string;
toPlaceholder?: string;
onChange: (value: { from: string; to: string }) => void;
}) {
const value = useMemo<[Dayjs | null, Dayjs | null] | null>(() => {
const from = parseDayjsDateTime(props.from);
const to = parseDayjsDateTime(props.to);
return from || to ? [from, to] : null;
}, [props.from, props.to]);
return (
<ConfigProvider locale={zhCN} theme={dateRangePickerTheme}>
<DatePicker.RangePicker
allowClear
className="shDateRangePicker"
classNames={{ popup: { root: 'shDateRangePickerPopup' } }}
disabled={props.disabled}
format={rangeDateTimeFormat}
needConfirm
placeholder={[props.fromPlaceholder ?? '开始日期', props.toPlaceholder ?? '结束日期']}
showTime={{ format: 'HH:mm:ss' }}
size="middle"
style={{ width: '100%' }}
value={value}
onChange={(nextValue) => {
props.onChange({
from: nextValue?.[0] ? nextValue[0].format(rangeDateTimeFormat) : '',
to: nextValue?.[1] ? nextValue[1].format(rangeDateTimeFormat) : '',
});
}}
/>
</ConfigProvider>
);
}
const rangeDateTimeFormat = 'YYYY-MM-DD HH:mm:ss';
const rangeParseFormats = [rangeDateTimeFormat, 'YYYY-MM-DDTHH:mm:ss', 'YYYY-MM-DDTHH:mm', 'YYYY-MM-DD'];
const dateRangePickerTheme = {
token: {
colorPrimary: '#18181b',
colorPrimaryHover: '#27272a',
colorBorder: '#e4e4e7',
colorText: '#27272a',
colorTextPlaceholder: '#71717a',
colorBgContainer: '#ffffff',
colorBgElevated: '#ffffff',
colorFillSecondary: '#f4f4f5',
borderRadius: 6,
controlHeight: 36,
fontFamily: 'inherit',
fontSize: 14,
},
};
function parseLocalDateTime(value: string) {
if (!value) return undefined;
const date = new Date(value);
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? undefined : date;
}
@@ -82,6 +151,14 @@ function formatTimeValue(date: Date) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function parseDayjsDateTime(value: string): Dayjs | null {
if (!value) return null;
const parsed = dayjs(value, rangeParseFormats, true);
if (parsed.isValid()) return parsed;
const fallback = dayjs(value);
return fallback.isValid() ? fallback : null;
}
function pad(value: number) {
return String(value).padStart(2, '0');
}
+37 -6
View File
@@ -1,9 +1,40 @@
import * as React from 'react';
import { cn } from '../../lib/utils';
export function Table(props: React.HTMLAttributes<HTMLDivElement>) {
export type TableDensity = 'standard' | 'compact';
export interface TableProps extends React.HTMLAttributes<HTMLDivElement> {
density?: TableDensity;
}
export function Table(props: TableProps) {
const { className, density = 'standard', ...rest } = props;
return <div className={cn('shTable', density === 'compact' ? 'shTableCompact' : 'shTableStandard', className)} role="table" {...rest} />;
}
export function TableViewportLayout(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTable', className)} role="table" {...rest} />;
return <div className={cn('shTableViewportLayout', className)} {...rest} />;
}
export function TableToolbar(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTableToolbar', className)} {...rest} />;
}
export function TableSummary(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTableSummary', className)} {...rest} />;
}
export function TableFooter(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTableFooter', className)} {...rest} />;
}
export function TablePageActions(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <div className={cn('shTablePageActions', className)} {...rest} />;
}
export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
@@ -11,14 +42,14 @@ export function TableRow(props: React.HTMLAttributes<HTMLDivElement>) {
return <div className={cn('shTableRow', className)} role="row" {...rest} />;
}
export function TableHead(props: React.HTMLAttributes<HTMLSpanElement>) {
export function TableHead(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <span className={cn('shTableHead', className)} role="columnheader" {...rest} />;
return <div className={cn('shTableHead', className)} role="columnheader" {...rest} />;
}
export function TableCell(props: React.HTMLAttributes<HTMLSpanElement>) {
export function TableCell(props: React.HTMLAttributes<HTMLDivElement>) {
const { className, ...rest } = props;
return <span className={cn('shTableCell', className)} role="cell" {...rest} />;
return <div className={cn('shTableCell', className)} role="cell" {...rest} />;
}
export function EmptyState(props: { title: string; description?: string }) {
+1
View File
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { App } from './App';
import 'katex/dist/katex.min.css';
import 'streamdown/styles.css';
import 'antd/dist/reset.css';
import './styles.css';
createRoot(document.getElementById('root')!).render(
+123 -264
View File
@@ -1,156 +1,37 @@
import { useMemo, useState } from 'react';
import { Boxes, Search } from 'lucide-react';
import type {
BaseModelCatalogItem,
BillingConfig,
CatalogProvider,
PlatformModel,
} from '@easyai-ai-gateway/contracts';
import { useEffect, useMemo, useState } from 'react';
import { Boxes, Check, Search, X } from 'lucide-react';
import type { ModelCatalogFilterOption, ModelCatalogItem, ModelCatalogPermission } from '@easyai-ai-gateway/contracts';
import type { ConsoleData } from '../app-state';
import { Badge, Card, CardContent, Input } from '../components/ui';
import { stableModelAlias } from './admin/platform-form';
type ModelListItem = {
id: string;
providerKey: string;
platformName?: string;
modelName: string;
modelAlias?: string;
modelType: string[];
displayName: string;
capabilities?: Record<string, unknown>;
pricingMode: string;
billingConfig?: BillingConfig;
billingConfigOverride?: BillingConfig;
enabled: boolean;
};
const capabilityFilters = [
{ value: 'all', label: '全部' },
{ value: 'chat', label: '对话' },
{ value: 'image', label: '绘图' },
{ value: 'video', label: '视频' },
{ value: 'audio', label: '音频' },
{ value: 'embedding', label: 'Embedding' },
];
const publicProviders: CatalogProvider[] = [
{
id: 'public-openai',
providerKey: 'openai',
code: 'openai',
displayName: 'OpenAI',
providerType: 'openai',
source: 'server-main.integration-platform',
status: 'active',
createdAt: '',
updatedAt: '',
},
{
id: 'public-gemini',
providerKey: 'gemini',
code: 'google-gemini',
displayName: 'Google Gemini',
providerType: 'gemini',
iconPath: 'https://static.51easyai.com/gemini-color.png',
source: 'server-main.integration-platform',
status: 'active',
createdAt: '',
updatedAt: '',
},
];
const publicModels: PlatformModel[] = [
{
id: 'public-openai-gpt-4o-mini',
platformId: 'public-openai',
provider: 'OpenAI',
platformName: 'OpenAI Simulation',
modelName: 'gpt-4o-mini',
modelAlias: 'gpt-4o-mini',
modelType: ['text_generate'],
displayName: 'gpt-4o-mini',
capabilities: { multimodal: true },
pricingMode: 'inherit',
enabled: true,
createdAt: '',
updatedAt: '',
},
{
id: 'public-openai-gpt-image-1',
platformId: 'public-openai',
provider: 'OpenAI',
platformName: 'OpenAI Simulation',
modelName: 'gpt-image-1',
modelAlias: 'gpt-image-1',
modelType: ['image_generate', 'image_edit'],
displayName: 'gpt-image-1',
capabilities: { imageEdit: true },
pricingMode: 'inherit',
enabled: true,
createdAt: '',
updatedAt: '',
},
{
id: 'public-gemini-flash',
platformId: 'public-gemini',
provider: 'Gemini',
platformName: 'Gemini Simulation',
modelName: 'gemini-2.0-flash',
modelAlias: 'gemini-2.0-flash',
modelType: ['text_generate'],
displayName: 'gemini-2.0-flash',
capabilities: { multimodal: true, vision: true },
pricingMode: 'inherit_discount',
enabled: true,
createdAt: '',
updatedAt: '',
},
];
import { Card, CardContent, Input } from '../components/ui';
export function ModelsPage(props: { data: ConsoleData }) {
const catalog = props.data.modelCatalog;
const [query, setQuery] = useState('');
const [provider, setProvider] = useState('all');
const [capability, setCapability] = useState('all');
const sourceProviders = props.data.providers.length ? props.data.providers : publicProviders;
const providerMap = useMemo(() => buildProviderMap(sourceProviders), [sourceProviders]);
const sourceModels = useMemo(() => {
if (props.data.models.length) {
return props.data.models.map(modelFromPlatform);
}
if (props.data.baseModels.length) {
return props.data.baseModels.map(modelFromBaseModel);
}
return publicModels.map(modelFromPlatform);
}, [props.data.baseModels, props.data.models]);
const providerOptions = catalog.filters.providers.length ? catalog.filters.providers : [{ value: 'all', label: '全部', count: catalog.items.length }];
const capabilityOptions = catalog.filters.capabilities.length ? catalog.filters.capabilities : [{ value: 'all', label: '全部', count: catalog.items.length }];
const providerOptions = useMemo(() => {
const options = new Map<string, string>();
sourceProviders
.filter((item) => item.status !== 'hidden')
.forEach((item) => options.set(item.providerKey, item.displayName));
sourceModels.forEach((model) => {
if (!options.has(model.providerKey)) {
options.set(model.providerKey, providerMap.get(model.providerKey)?.displayName ?? model.providerKey);
}
});
return [
{ value: 'all', label: '全部' },
...Array.from(options.entries())
.sort((a, b) => a[1].localeCompare(b[1]))
.map(([value, label]) => ({ value, label })),
];
}, [providerMap, sourceModels, sourceProviders]);
useEffect(() => {
if (!providerOptions.some((item) => item.value === provider)) setProvider('all');
}, [provider, providerOptions]);
useEffect(() => {
if (!capabilityOptions.some((item) => item.value === capability)) setCapability('all');
}, [capability, capabilityOptions]);
const filteredModels = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return sourceModels.filter((model) => {
const matchedProvider = provider === 'all' || model.providerKey === provider;
const matchedCapability = modelMatchesCapability(model.modelType, capability);
const matchedQuery = [
model.modelName,
model.modelAlias,
return catalog.items.filter((model) => {
const matchedProvider = provider === 'all' || model.providerKeys.includes(provider);
const matchedCapability = capability === 'all' || modelMatchesCapability(model, capability);
const matchedQuery = !normalizedQuery || [
model.alias,
model.displayName,
model.modelName,
model.description,
...model.providers.map((item) => item.name),
...model.capabilityTags,
]
.filter(Boolean)
.join(' ')
@@ -158,14 +39,14 @@ export function ModelsPage(props: { data: ConsoleData }) {
.includes(normalizedQuery);
return matchedProvider && matchedCapability && matchedQuery;
});
}, [capability, provider, query, sourceModels]);
}, [capability, catalog.items, provider, query]);
return (
<div className="modelsPage">
<aside className="modelFilters">
<FilterGroup
title="模型能力"
items={capabilityFilters}
items={capabilityOptions}
value={capability}
onChange={setCapability}
/>
@@ -179,16 +60,16 @@ export function ModelsPage(props: { data: ConsoleData }) {
<main className="modelsContent">
<div className="modelsToolbar">
<p> {sourceModels.length} {filteredModels.length} </p>
<p> {catalog.summary.sourceCount} {catalog.summary.modelCount} {filteredModels.length} </p>
<div className="searchField modelHeaderSearch">
<Search size={16} />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称模糊搜索" />
<Input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="模型名称、能力或厂商" />
</div>
</div>
<section className="modelCards">
{filteredModels.map((model) => (
<ModelCard model={model} provider={providerMap.get(model.providerKey)} key={model.id} />
<ModelCard model={model} key={model.id} />
))}
{!filteredModels.length && (
<Card>
@@ -205,7 +86,7 @@ export function ModelsPage(props: { data: ConsoleData }) {
}
function FilterGroup(props: {
items: Array<{ value: string; label: string }>;
items: ModelCatalogFilterOption[];
title: string;
value: string;
onChange: (value: string) => void;
@@ -222,7 +103,9 @@ function FilterGroup(props: {
key={item.value}
onClick={() => props.onChange(item.value)}
>
{item.label}
<FilterIcon item={item} />
<span>{item.label}</span>
<em>{item.count}</em>
</button>
))}
</div>
@@ -230,104 +113,118 @@ function FilterGroup(props: {
);
}
function ModelCard(props: { model: ModelListItem; provider?: CatalogProvider }) {
const tags = tagsForModel(props.model);
const providerName = props.provider?.displayName ?? props.model.providerKey;
function FilterIcon(props: { item: ModelCatalogFilterOption }) {
if (props.item.iconPath) {
return (
<span className="filterChipIcon">
<img src={props.item.iconPath} alt="" />
</span>
);
}
if (props.item.value === 'all') return null;
return <span className="filterChipIcon">{providerInitials(props.item.label)}</span>;
}
function ModelCard(props: { model: ModelCatalogItem }) {
const description = props.model.description || '暂无模型描述';
return (
<Card className="modelCard">
<CardContent>
<div className="modelCardTop">
<ProviderIcon provider={props.provider} label={providerName} />
<div>
<strong>{props.model.displayName || props.model.modelName}</strong>
<span>{providerName} · {props.model.platformName ?? props.provider?.code ?? 'catalog'}</span>
<ModelIcon iconPath={props.model.iconPath} label={props.model.displayName || props.model.alias} />
<div className="modelCardHeaderText">
<strong>{props.model.displayName || props.model.alias}</strong>
<p className="modelCardDescription text-xs" title={description}>{description}</p>
</div>
<Badge variant={props.model.enabled ? 'success' : 'secondary'}>
{props.model.enabled ? '启用' : '停用'}
</Badge>
</div>
<p>{props.model.modelAlias || props.model.modelName}</p>
<div className="modelTags">
{tags.map((tag) => <span key={tag}>{tag}</span>)}
</div>
<div className="modelCardFooter">
<span>{priceLabel(props.model)}</span>
<a href="#docs"></a>
<div className="modelCardIntro">
<div className="modelTags">
{props.model.capabilityTags.map((tag) => <span className="text-xs" key={tag}>{tag}</span>)}
</div>
</div>
<dl className="modelCardFacts text-xs">
<div>
<dt></dt>
<dd title={props.model.source.title}>{props.model.source.label}</dd>
</div>
<div>
<dt></dt>
<dd title={props.model.discount.title}>{props.model.discount.label}</dd>
</div>
<div className="modelCardFactRateLimit">
<dt></dt>
<dd title={props.model.rateLimits.title}>{props.model.rateLimits.label}</dd>
</div>
<div>
<dt></dt>
<dd title={props.model.permission.title}>
<PermissionValue permission={props.model.permission} />
</dd>
</div>
<div className="modelCardFactFull">
<dt></dt>
<dd title={props.model.pricing.title}>{props.model.pricing.lines.join('')}</dd>
</div>
</dl>
</CardContent>
</Card>
);
}
function ProviderIcon(props: { provider?: CatalogProvider; label?: string }) {
const label = props.label ?? props.provider?.displayName ?? props.provider?.providerKey ?? 'AI';
if (props.provider?.iconPath) {
function PermissionValue(props: { permission: ModelCatalogPermission }) {
const allowGroups = props.permission.allowGroups ?? [];
const denyGroups = props.permission.denyGroups ?? [];
if (!allowGroups.length && !denyGroups.length) {
return <span>{props.permission.label}</span>;
}
return (
<span className="modelPermissionTags">
{allowGroups.map((group) => (
<span className="modelPermissionTag modelPermissionTagAllow" key={`allow-${group}`}>
<Check aria-hidden="true" className="modelPermissionIcon" />
<span>{group}</span>
</span>
))}
{denyGroups.map((group) => (
<span className="modelPermissionTag modelPermissionTagDeny" key={`deny-${group}`}>
<X aria-hidden="true" className="modelPermissionIcon" />
<span>{group}</span>
</span>
))}
</span>
);
}
function ModelIcon(props: { iconPath?: string; label: string }) {
if (props.iconPath) {
return (
<div className="modelIcon modelIconImage">
<img src={props.provider.iconPath} alt="" />
<img src={props.iconPath} alt="" />
</div>
);
}
return <div className="modelIcon">{providerInitials(label)}</div>;
return <div className="modelIcon">{providerInitials(props.label)}</div>;
}
function buildProviderMap(providers: CatalogProvider[]) {
const map = new Map<string, CatalogProvider>();
providers.forEach((provider) => {
[
provider.providerKey,
provider.code,
provider.displayName,
stringMetadata(provider.metadata, 'sourceCode'),
].filter(Boolean).forEach((key) => map.set(normalizeProviderKey(key), provider));
map.set(provider.providerKey, provider);
});
return map;
function modelMatchesCapability(model: ModelCatalogItem, capability: string) {
if (model.modelType.some((type) => modelTypeMatchesCapability(type, capability))) return true;
if (capability === 'tools') return model.capabilityTags.includes('工具调用');
if (capability === 'omni') return model.capabilityTags.includes('全模态');
return false;
}
function modelFromPlatform(model: PlatformModel): ModelListItem {
return {
id: model.id,
providerKey: normalizeProviderKey(model.provider ?? model.platformName ?? ''),
platformName: model.platformName,
modelName: model.modelName,
modelAlias: model.modelAlias,
modelType: model.modelType,
displayName: model.displayName,
capabilities: model.capabilities ?? model.capabilityOverride,
pricingMode: model.pricingMode,
billingConfig: model.billingConfig,
billingConfigOverride: model.billingConfigOverride,
enabled: model.enabled,
};
}
function modelFromBaseModel(model: BaseModelCatalogItem): ModelListItem {
return {
id: model.id,
providerKey: model.providerKey,
modelName: model.providerModelName,
modelAlias: stableModelAlias(model),
modelType: model.modelType,
displayName: stableModelAlias(model),
capabilities: model.capabilities,
pricingMode: 'inherit',
billingConfig: model.baseBillingConfig,
enabled: model.status === 'active',
};
}
function normalizeProviderKey(value: string) {
const normalized = value.trim().toLowerCase();
if (!normalized) return 'unknown';
if (normalized.includes('gemini')) return 'gemini';
if (normalized.includes('openai')) return 'openai';
return normalized.replace(/\s+/g, '-');
}
function stringMetadata(metadata: Record<string, unknown> | undefined, key: string) {
const value = metadata?.[key];
return typeof value === 'string' ? value : '';
function modelTypeMatchesCapability(value: string, capability: string) {
const type = value.trim().toLowerCase();
if (capability === 'chat') return type === 'text_generate' || type === 'chat' || type === 'responses' || type.includes('text');
if (capability === 'image') return type.includes('image') && !type.includes('video');
if (capability === 'video') return type.includes('video');
if (capability === 'audio') return type.includes('audio') || type.includes('speech');
if (capability === 'embedding') return type === 'text_embedding' || type === 'embedding';
if (capability === 'tools') return type === 'tools_call';
if (capability === 'omni') return type === 'omni' || type === 'omni_video';
return type === capability;
}
function providerInitials(label: string) {
@@ -338,41 +235,3 @@ function providerInitials(label: string) {
.slice(0, 2)
.toUpperCase() || 'AI';
}
function tagsForModel(model: ModelListItem) {
const tags = model.modelType.map(capabilityName);
const capabilities = model.capabilities ?? {};
if (capabilities.multimodal || capabilities.vision) tags.push('多模态');
if (capabilities.reasoning) tags.push('推理');
if (model.pricingMode === 'inherit_discount') tags.push('折扣');
if (model.pricingMode === 'custom') tags.push('自定义价');
return tags;
}
function capabilityName(type: string) {
const labels: Record<string, string> = {
text_generate: '对话',
image_generate: '绘图',
image_edit: '图像编辑',
video_generate: '视频',
image_to_video: '图生视频',
audio_generate: '音频',
};
return labels[type] ?? capabilityFilters.find((item) => item.value === type)?.label ?? type;
}
function modelMatchesCapability(modelTypes: string[], capability: string) {
if (capability === 'all') return true;
if (capability === 'chat') return modelTypes.includes('text_generate') || modelTypes.includes('chat');
if (capability === 'image') return modelTypes.some((type) => type.includes('image'));
if (capability === 'video') return modelTypes.some((type) => type.includes('video'));
return modelTypes.includes(capability);
}
function priceLabel(model: ModelListItem) {
const config = model.billingConfig ?? model.billingConfigOverride;
if (typeof config?.basePrice === 'number') {
return `${config.basePrice}/${config.unit ?? config.resourceType ?? 'unit'}`;
}
return model.pricingMode === 'inherit' ? '跟随基准定价' : model.pricingMode;
}
+313 -51
View File
@@ -1,11 +1,11 @@
import { useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { Copy, CreditCard, KeyRound, ListChecks, Plus, ShieldCheck, Trash2, UserRound } from 'lucide-react';
import { useEffect, useMemo, useState, type FormEvent, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, Copy, CreditCard, Eye, KeyRound, ListChecks, Plus, RotateCcw, Search, ShieldCheck, Trash2, UserRound } from 'lucide-react';
import type { GatewayAccessRuleBatchRequest, GatewayApiKey, GatewayTask, IntegrationPlatform, PlatformModel } from '@easyai-ai-gateway/contracts';
import type { ConsoleData } from '../app-state';
import { EntityTable } from '../components/EntityTable';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow, Tabs } from '../components/ui';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, ConfirmDialog, DateTimePicker, DateTimeRangePicker, FormDialog, Input, Label, Select, Table, TableCell, TableFooter, TableHead, TablePageActions, TableRow, TableToolbar, TableViewportLayout, Tabs } from '../components/ui';
import { AccessPermissionEditor, countAccessPermissionRules } from './admin/AccessPermissionEditor';
import type { ApiKeyForm, LoadState, WorkspaceSection } from '../types';
import type { ApiKeyForm, LoadState, WorkspaceSection, WorkspaceTaskQuery } from '../types';
const tabs = [
{ value: 'overview', label: '个人总览', icon: <UserRound size={15} /> },
@@ -14,6 +14,8 @@ const tabs = [
{ value: 'tasks', label: '任务记录', icon: <ListChecks size={15} /> },
] satisfies Array<{ value: WorkspaceSection; label: string; icon: ReactNode }>;
const taskPageSizeOptions = [10, 20, 50];
export function WorkspacePage(props: {
apiKeyForm: ApiKeyForm;
apiKeySecret: string;
@@ -23,11 +25,14 @@ export function WorkspacePage(props: {
message: string;
section: WorkspaceSection;
state: LoadState;
taskQuery: WorkspaceTaskQuery;
taskTotal: number;
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
onDeleteApiKey: (apiKeyId: string) => Promise<void>;
onApiKeyFormChange: (value: ApiKeyForm) => void;
onSectionChange: (value: WorkspaceSection) => void;
onSubmitApiKey: (event: FormEvent<HTMLFormElement>) => void | Promise<void>;
onTaskQueryChange: (value: WorkspaceTaskQuery) => void;
onUseApiKeyForPlayground: (apiKeyId?: string) => void;
}) {
return (
@@ -38,7 +43,7 @@ export function WorkspacePage(props: {
{props.section === 'overview' && <WorkspaceOverview data={props.data} />}
{props.section === 'billing' && <BillingPanel />}
{props.section === 'apiKeys' && <ApiKeyPanel {...props} />}
{props.section === 'tasks' && <TaskPanel data={props.data} />}
{props.section === 'tasks' && <TaskPanel data={props.data} query={props.taskQuery} total={props.taskTotal} onQueryChange={props.onTaskQueryChange} />}
</div>
</div>
</div>
@@ -296,62 +301,319 @@ function ApiKeyPanel(props: {
);
}
function TaskPanel(props: { data: ConsoleData }) {
const tasks = useMemo(() => {
const latest = props.data.taskResult;
if (!latest) return props.data.tasks;
return [latest, ...props.data.tasks.filter((item) => item.id !== latest.id)];
}, [props.data.taskResult, props.data.tasks]);
function TaskPanel(props: {
data: ConsoleData;
query: WorkspaceTaskQuery;
total: number;
onQueryChange: (value: WorkspaceTaskQuery) => void;
}) {
const [localMessage, setLocalMessage] = useState('');
const [jsonTask, setJsonTask] = useState<GatewayTask | null>(null);
const [pageJump, setPageJump] = useState(String(props.query.page));
const taskQuery = props.query;
const tasks = props.data.tasks;
const taskTypes = useMemo(() => {
const knownTypes = ['text_generate', 'image_generate', 'image_edit', 'video_generate', 'image_to_video'];
const values = [...knownTypes, taskQuery.modelType, ...tasks.map((task) => task.modelType)];
return Array.from(new Set(values.filter((value): value is string => Boolean(value)))).sort((a, b) => a.localeCompare(b));
}, [taskQuery.modelType, tasks]);
const pageSizeOptions = useMemo(() => {
return Array.from(new Set([...taskPageSizeOptions, taskQuery.pageSize])).sort((a, b) => a - b);
}, [taskQuery.pageSize]);
const totalPages = Math.max(1, Math.ceil(props.total / taskQuery.pageSize));
const currentPage = Math.min(taskQuery.page, totalPages);
const pageStart = props.total ? Math.min((currentPage - 1) * taskQuery.pageSize + 1, props.total) : 0;
const pageEnd = Math.min(currentPage * taskQuery.pageSize, props.total);
const hasActiveFilters = Boolean(taskQuery.query || taskQuery.createdFrom || taskQuery.createdTo || taskQuery.modelType);
useEffect(() => {
if (taskQuery.page > totalPages) {
props.onQueryChange({ ...taskQuery, page: totalPages });
}
}, [props.onQueryChange, taskQuery, totalPages]);
useEffect(() => {
setPageJump(String(currentPage));
}, [currentPage]);
async function copyTaskRequestId(task: GatewayTask) {
if (!task.requestId) return;
await navigator.clipboard.writeText(task.requestId);
setLocalMessage(`已复制 RequestID${task.requestId}`);
}
function resetFilters() {
props.onQueryChange({
query: '',
modelType: '',
createdFrom: '',
createdTo: '',
page: 1,
pageSize: taskQuery.pageSize,
});
}
function updateQuery(value: string) {
props.onQueryChange({ ...taskQuery, query: value, page: 1 });
}
function updateTypeFilter(value: string) {
props.onQueryChange({ ...taskQuery, modelType: value === 'all' ? '' : value, page: 1 });
}
function updateCreatedRange(value: { from: string; to: string }) {
props.onQueryChange({ ...taskQuery, createdFrom: value.from, createdTo: value.to, page: 1 });
}
function updatePageSize(value: string) {
const nextPageSize = Number(value);
props.onQueryChange({ ...taskQuery, page: 1, pageSize: Number.isFinite(nextPageSize) ? nextPageSize : 10 });
}
function updatePage(page: number) {
props.onQueryChange({ ...taskQuery, page });
}
function submitPageJump() {
const parsed = Number(pageJump);
if (!Number.isFinite(parsed) || parsed <= 0) {
setPageJump(String(currentPage));
return;
}
updatePage(Math.min(totalPages, Math.max(1, Math.floor(parsed))));
}
return (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
{tasks.length ? (
<div className="taskList">
{tasks.map((task) => (
<TaskRecord key={task.id} task={task} />
))}
</div>
) : (
<div className="emptyState">
<strong></strong>
</div>
)}
</CardContent>
</Card>
<>
<Card className="shTableViewportCard taskRecordViewport">
<CardContent className="shTableViewportPanel">
{localMessage && <p className="formMessage">{localMessage}</p>}
<TableViewportLayout>
<TableToolbar className="taskRecordFilters">
<Label className="taskRecordSearchLabel">
<span className="taskRecordSearchBox">
<Search size={15} />
<Input
value={taskQuery.query}
placeholder="搜索 ID / RequestID / 模型 / API Key"
onChange={(event) => updateQuery(event.target.value)}
/>
</span>
</Label>
<Label>
<Select value={taskQuery.modelType || 'all'} onChange={(event) => updateTypeFilter(event.target.value)}>
<option value="all"></option>
{taskTypes.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</Select>
</Label>
<Label className="taskRecordRangeLabel">
<DateTimeRangePicker
from={taskQuery.createdFrom}
fromPlaceholder="开始日期"
to={taskQuery.createdTo}
toPlaceholder="结束日期"
onChange={updateCreatedRange}
/>
</Label>
<Button type="button" variant="outline" size="sm" disabled={!hasActiveFilters} onClick={resetFilters}>
<RotateCcw size={14} />
</Button>
</TableToolbar>
{tasks.length ? (
<Table className="shTableViewport taskRecordTable" density="compact">
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead>RequestID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead>Token</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> JSON</TableHead>
</TableRow>
{tasks.map((task) => (
<TaskRecord key={task.id} task={task} onCopyRequestId={copyTaskRequestId} onOpenJson={setJsonTask} />
))}
</Table>
) : (
<div className="emptyState">
<strong>{hasActiveFilters ? '没有匹配的任务' : '暂无任务'}</strong>
{hasActiveFilters && <span></span>}
</div>
)}
<TableFooter>
<div className="shTableFooterGroup">
<Label>
<Select size="sm" value={String(taskQuery.pageSize)} onChange={(event) => updatePageSize(event.target.value)}>
{pageSizeOptions.map((option) => (
<option key={option} value={option}>{option} </option>
))}
</Select>
</Label>
<span> {props.total} · {pageStart}-{pageEnd}</span>
</div>
<form
className="shTablePageJump"
onSubmit={(event) => {
event.preventDefault();
submitPageJump();
}}
>
<span> {currentPage} / {totalPages} </span>
<span></span>
<Input
aria-label="跳转页码"
inputMode="numeric"
min={1}
max={totalPages}
size="xs"
type="number"
value={pageJump}
onChange={(event) => setPageJump(event.target.value)}
/>
<span></span>
<Button type="submit" variant="outline" size="xs" disabled={totalPages <= 1}></Button>
</form>
<TablePageActions>
<Button type="button" variant="outline" size="sm" disabled={currentPage <= 1} onClick={() => updatePage(Math.max(1, currentPage - 1))}>
<ChevronLeft size={14} />
</Button>
<Button type="button" variant="outline" size="sm" disabled={currentPage >= totalPages} onClick={() => updatePage(Math.min(totalPages, currentPage + 1))}>
<ChevronRight size={14} />
</Button>
</TablePageActions>
</TableFooter>
</TableViewportLayout>
</CardContent>
</Card>
<FormDialog
ariaLabel="查看任务原始 JSON"
bodyClassName="taskJsonDialogBody"
className="taskJsonDialog"
footer={<Button type="button" size="sm" onClick={() => setJsonTask(null)}></Button>}
open={Boolean(jsonTask)}
title="任务原始 JSON"
onClose={() => setJsonTask(null)}
onSubmit={(event) => event.preventDefault()}
>
<pre className="taskJsonPreview">{JSON.stringify(jsonTask, null, 2)}</pre>
</FormDialog>
</>
);
}
function TaskRecord(props: { task: GatewayTask }) {
function TaskRecord(props: { task: GatewayTask; onCopyRequestId: (task: GatewayTask) => Promise<void>; onOpenJson: (task: GatewayTask) => void }) {
const usage = props.task.usage ?? {};
const tokenText = usage.totalTokens ? `${usage.totalTokens}` : '-';
const chargeText = props.task.finalChargeAmount !== undefined ? `${props.task.finalChargeAmount}` : '-';
const tokenUsage = formatTokenUsage(usage);
const chargeText = props.task.finalChargeAmount !== undefined ? formatCellValue(props.task.finalChargeAmount) : '-';
const resolvedModel = props.task.resolvedModel || props.task.model;
const badgeVariant = props.task.status === 'succeeded' ? 'success' : props.task.status === 'failed' ? 'destructive' : 'secondary';
return (
<div className="taskPreview">
<div className="taskRecordHeader">
<Badge variant={badgeVariant}>{props.task.status}</Badge>
<strong>{props.task.kind}</strong>
<span>{props.task.model}</span>
<span>{formatDateTime(props.task.createdAt)}</span>
</div>
<div className="infoGrid compact">
<InfoItem label="API Key" value={props.task.apiKeyName || props.task.apiKeyId || '-'} />
<InfoItem label="RequestID" value={props.task.requestId || '-'} />
<InfoItem label="模型类型" value={props.task.modelType || '-'} />
<InfoItem label="实际模型" value={props.task.resolvedModel || props.task.model} />
<InfoItem label="Token" value={tokenText} />
<InfoItem label="扣费" value={chargeText} />
<InfoItem label="响应耗时" value={props.task.responseDurationMs ? `${props.task.responseDurationMs}ms` : '-'} />
<InfoItem label="错误" value={props.task.errorCode || props.task.errorMessage || '-'} />
</div>
<pre>{JSON.stringify({ result: props.task.result, usage: props.task.usage, billings: props.task.billings, billingSummary: props.task.billingSummary, metrics: props.task.metrics }, null, 2)}</pre>
</div>
<TableRow>
<TableCell>
<span className="taskRecordPrimaryCell taskRecordIdentityCell">
<strong>{props.task.kind}</strong>
<span className="taskRecordIdLine">
<span>ID</span>
<code title={props.task.id}>{props.task.id}</code>
</span>
</span>
</TableCell>
<TableCell>
<span className="taskRecordRequestLine">
<code title={props.task.requestId || '-'}>{props.task.requestId || '-'}</code>
<Button
type="button"
variant="ghost"
size="icon"
title={props.task.requestId ? '复制 RequestID' : '暂无 RequestID'}
disabled={!props.task.requestId}
onClick={() => void props.onCopyRequestId(props.task)}
>
<Copy size={14} />
</Button>
</span>
</TableCell>
<TableCell><Badge variant={badgeVariant}>{props.task.status}</Badge></TableCell>
<TableCell className="taskRecordModelCell">
<span className="taskRecordPrimaryCell">
<strong>{resolvedModel}</strong>
{props.task.requestedModel && props.task.requestedModel !== resolvedModel && <small>{props.task.requestedModel}</small>}
</span>
</TableCell>
<TableCell>{props.task.modelType || '-'}</TableCell>
<TableCell>{props.task.apiKeyName || props.task.apiKeyPrefix || props.task.apiKeyId || '-'}</TableCell>
<TableCell className="taskRecordTokenCell">{tokenUsage}</TableCell>
<TableCell>{chargeText}</TableCell>
<TableCell>{formatDuration(props.task.responseDurationMs)}</TableCell>
<TableCell>{formatDateTime(props.task.createdAt)}</TableCell>
<TableCell>
<Button type="button" variant="ghost" size="sm" className="taskRecordJsonButton" title={taskErrorText(props.task) || '查看原始 JSON'} onClick={() => props.onOpenJson(props.task)}>
<Eye size={14} />
JSON
</Button>
</TableCell>
</TableRow>
);
}
function formatCellValue(value: unknown) {
if (value === undefined || value === null || value === '') return '-';
return String(value);
}
function formatTokenUsage(usage: Record<string, unknown>) {
const input = tokenValue(usage.inputTokens ?? usage.promptTokens ?? usage.input_tokens ?? usage.prompt_tokens);
const output = tokenValue(usage.outputTokens ?? usage.completionTokens ?? usage.output_tokens ?? usage.completion_tokens);
const total = tokenValue(usage.totalTokens ?? usage.total_tokens ?? (input !== null && output !== null ? input + output : null));
if (input === null && output === null && total === null) return '-';
return (
<span className="taskRecordTokenUsage">
<span>{formatCellValue(input)}/{formatCellValue(output)}</span>
<span>{formatCellValue(total)}</span>
</span>
);
}
function tokenValue(value: unknown) {
if (value === undefined || value === null || value === '') return null;
const numericValue = typeof value === 'number' ? value : Number(value);
return Number.isFinite(numericValue) ? numericValue : null;
}
function formatDuration(value?: number) {
if (value === undefined || value === null) return '-';
const milliseconds = Math.max(0, Math.round(value));
if (milliseconds === 0) return '0秒';
if (milliseconds < 1000) return `${milliseconds}毫秒`;
const totalSeconds = Math.round(milliseconds / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
if (hours > 0) return `${hours}小时${minutes}${seconds}`;
if (minutes > 0) return `${minutes}${seconds}`;
return `${seconds}`;
}
function taskErrorText(task: GatewayTask) {
return task.errorCode || task.errorMessage || task.error || '';
}
function InfoItem(props: { label: string; value: string }) {
return (
<div className="infoItem">
@@ -58,6 +58,7 @@ export function PlatformManagementPanel(props: {
const text = [
model.displayName,
model.modelName,
model.providerModelName,
model.modelAlias,
...model.modelType,
model.provider,
@@ -482,9 +483,9 @@ function PlatformModelTable(props: {
meta={[
platform ? platformDisplayName(platform) : model.platformName ?? '-',
provider?.displayName ?? platform?.provider ?? model.provider ?? '-',
model.modelAlias || model.baseModelId || '-',
model.modelAlias || model.modelName || '-',
]}
subtitle={model.modelName}
subtitle={model.providerModelName ? `调用模型名:${model.providerModelName}` : model.modelName}
title={model.displayName || model.modelName}
/>
);
@@ -608,10 +609,13 @@ function ModelSelection(props: {
const next = new Set(selectedIds);
next.delete(modelId);
const modelDiscountFactors = { ...props.form.modelDiscountFactors };
const modelNameMappings = { ...props.form.modelNameMappings };
delete modelDiscountFactors[modelId];
delete modelNameMappings[modelId];
props.onChange({
...props.form,
modelDiscountFactors,
modelNameMappings,
selectionMode: 'partial',
selectedModelIds: Array.from(next),
});
@@ -633,6 +637,16 @@ function ModelSelection(props: {
});
}
function updateModelNameMapping(modelId: string, value: string) {
props.onChange({
...props.form,
modelNameMappings: {
...props.form.modelNameMappings,
[modelId]: value,
},
});
}
return (
<div className="platformModelSelector spanTwo">
<div className="platformModelSelectorHeader">
@@ -649,26 +663,44 @@ function ModelSelection(props: {
<div className="platformModelEmpty"></div>
) : (
<div className="platformModelChoices">
{selectedModels.map((model) => (
<div className="platformModelChoice" key={model.id}>
<div className="platformModelChoiceMain">
<span>
<strong>{stableModelAlias(model) || model.providerModelName}</strong>
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
</span>
{selectedModels.map((model) => {
const modelLabel = stableModelAlias(model) || model.providerModelName;
const providerModelName = props.form.modelNameMappings[model.id] ?? model.providerModelName;
return (
<div className="platformModelChoice" key={model.id}>
<div className="platformModelChoiceMain">
<span>
<strong>{modelLabel}</strong>
<small>{props.providerMap.get(model.providerKey)?.displayName ?? model.providerKey} · {model.providerModelName} · {baseModelTypeText(model)}</small>
</span>
</div>
<div className="platformModelChoiceFields">
<Label>
<Input
aria-label={`${modelLabel} 调用模型名`}
placeholder={model.providerModelName}
value={providerModelName}
onChange={(event) => updateModelNameMapping(model.id, event.target.value)}
/>
</Label>
<Label>
<Input
aria-label={`${modelLabel} 折扣率`}
inputMode="decimal"
placeholder="继承"
value={props.form.modelDiscountFactors[model.id] ?? ''}
onChange={(event) => updateModelDiscount(model.id, event.target.value)}
/>
</Label>
</div>
<Button type="button" variant="ghost" size="icon" aria-label="移除模型" onClick={() => removeModel(model.id)}>
<Trash2 size={14} />
</Button>
</div>
<Input
aria-label={`${stableModelAlias(model) || model.providerModelName} 折扣率`}
inputMode="decimal"
placeholder="折扣率"
value={props.form.modelDiscountFactors[model.id] ?? ''}
onChange={(event) => updateModelDiscount(model.id, event.target.value)}
/>
<Button type="button" variant="ghost" size="icon" aria-label="移除模型" onClick={() => removeModel(model.id)}>
<Trash2 size={14} />
</Button>
</div>
))}
);
})}
</div>
)}
<ModelPickerDialog
@@ -864,6 +896,7 @@ function platformToForm(
supportUrlInput: readBoolean(config, 'supportUrlInput', true),
selectedModelIds: platformModelBaseIds(platform, baseModels, currentModels),
modelDiscountFactors: platformModelDiscountFactors(platform, baseModels, currentModels),
modelNameMappings: platformModelNameMappings(platform, baseModels, currentModels),
selectionMode: 'partial',
};
}
@@ -885,10 +918,19 @@ function platformModelDiscountFactors(platform: IntegrationPlatform, baseModels:
}, {});
}
function platformModelNameMappings(platform: IntegrationPlatform, baseModels: BaseModelCatalogItem[], platformModels: PlatformModel[]) {
return platformModels.reduce<Record<string, string>>((acc, model) => {
const baseModel = findBaseModelForPlatformModel(platform, baseModels, model);
if (baseModel?.id) acc[baseModel.id] = model.providerModelName || model.modelName;
return acc;
}, {});
}
function findBaseModelForPlatformModel(platform: IntegrationPlatform | undefined, baseModels: BaseModelCatalogItem[], model: PlatformModel) {
return baseModels.find((item) => item.id === model.baseModelId) ??
baseModels.find((item) => item.canonicalModelKey === model.modelAlias) ??
baseModels.find((item) => stableModelAlias(item) === model.modelAlias) ??
baseModels.find((item) => item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type))) ??
baseModels.find((item) => item.providerKey === platform?.provider && item.providerModelName === model.modelName && model.modelType.some((type) => baseModelTypes(item).includes(type)));
}
+6 -2
View File
@@ -34,6 +34,7 @@ export interface PlatformWizardForm {
supportUrlInput: boolean;
modelDiscountFactor: string;
modelDiscountFactors: Record<string, string>;
modelNameMappings: Record<string, string>;
modelOverrideRetry: boolean;
modelRetryEnabled: boolean;
modelRetryMaxAttempts: string;
@@ -78,6 +79,7 @@ export function createEmptyPlatformForm(provider = '', defaults?: ProviderConnec
supportUrlInput: true,
modelDiscountFactor: '',
modelDiscountFactors: {},
modelNameMappings: {},
modelOverrideRetry: false,
modelRetryEnabled: true,
modelRetryMaxAttempts: '2',
@@ -99,6 +101,7 @@ export function applyProviderDefaults(form: PlatformWizardForm, provider: string
authType: defaults?.defaultAuthType ?? 'APIKey',
selectedModelIds: [],
modelDiscountFactors: {},
modelNameMappings: {},
};
}
@@ -148,6 +151,7 @@ export function platformModelPayloads(models: BaseModelCatalogItem[], form: Plat
baseModelId: model.id,
canonicalModelKey: model.canonicalModelKey,
modelName: model.providerModelName,
providerModelName: optionalString(form.modelNameMappings[model.id]) ?? model.providerModelName,
modelAlias: stableModelAlias(model),
modelType: baseModelTypes(model),
displayName: stableModelAlias(model) || model.providerModelName,
@@ -329,8 +333,8 @@ function limitRule(metric: string, value: string, windowSeconds = 60) {
};
}
function optionalString(value: string) {
const trimmed = value.trim();
function optionalString(value: string | null | undefined) {
const trimmed = value?.trim() ?? '';
return trimmed || undefined;
}
+72 -8
View File
@@ -1,4 +1,4 @@
import type { AdminSection, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection } from './types';
import type { AdminSection, ApiDocSection, PageKey, PlaygroundMode, WorkspaceSection, WorkspaceTaskQuery } from './types';
export interface AppRouteState {
activePage: PageKey;
@@ -6,6 +6,7 @@ export interface AppRouteState {
apiDocSection: ApiDocSection;
playgroundMode: PlaygroundMode;
workspaceSection: WorkspaceSection;
workspaceTaskQuery: WorkspaceTaskQuery;
}
export const defaultRouteState: AppRouteState = {
@@ -14,6 +15,7 @@ export const defaultRouteState: AppRouteState = {
apiDocSection: 'chat',
playgroundMode: 'chat',
workspaceSection: 'overview',
workspaceTaskQuery: defaultWorkspaceTaskQuery(),
};
const workspacePaths: Record<WorkspaceSection, string> = {
@@ -55,21 +57,23 @@ const adminSections = reverseMap(adminPaths);
const docsSections = reverseMap(docsPaths);
const playgroundSections = reverseMap(playgroundPaths);
export function parseAppRoute(pathname = window.location.pathname): AppRouteState {
const path = normalizePath(pathname);
export function parseAppRoute(input = `${window.location.pathname}${window.location.search}`): AppRouteState {
const url = new URL(input, window.location.origin);
const path = normalizePath(url.pathname);
const workspaceTaskQuery = parseWorkspaceTaskQuery(url.searchParams);
if (path === '/') return { ...defaultRouteState };
if (path.startsWith('/playground')) {
return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path) };
return { ...defaultRouteState, activePage: 'playground', playgroundMode: parsePlaygroundMode(path), workspaceTaskQuery };
}
if (path === '/models') return { ...defaultRouteState, activePage: 'models' };
if (path === '/models') return { ...defaultRouteState, activePage: 'models', workspaceTaskQuery };
if (path.startsWith('/workspace')) {
return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path) };
return { ...defaultRouteState, activePage: 'workspace', workspaceSection: parseWorkspaceSection(path), workspaceTaskQuery };
}
if (path.startsWith('/admin')) {
return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path) };
return { ...defaultRouteState, activePage: 'admin', adminSection: parseAdminSection(path), workspaceTaskQuery };
}
if (path.startsWith('/docs')) {
return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path) };
return { ...defaultRouteState, activePage: 'docs', apiDocSection: parseDocSection(path), workspaceTaskQuery };
}
return { ...defaultRouteState };
}
@@ -87,6 +91,19 @@ export function pathForWorkspaceSection(section: WorkspaceSection) {
return workspacePaths[section] ?? workspacePaths.overview;
}
export function pathForWorkspaceTaskQuery(query: WorkspaceTaskQuery) {
const normalized = normalizeWorkspaceTaskQuery(query);
const search = new URLSearchParams();
if (normalized.query) search.set('q', normalized.query);
if (normalized.modelType) search.set('type', normalized.modelType);
if (normalized.createdFrom) search.set('from', normalized.createdFrom);
if (normalized.createdTo) search.set('to', normalized.createdTo);
if (normalized.page !== 1) search.set('page', String(normalized.page));
if (normalized.pageSize !== 10) search.set('pageSize', String(normalized.pageSize));
const suffix = search.toString();
return `${workspacePaths.tasks}${suffix ? `?${suffix}` : ''}`;
}
export function pathForAdminSection(section: AdminSection) {
return adminPaths[section] ?? adminPaths.overview;
}
@@ -131,3 +148,50 @@ function normalizePath(pathname: string) {
function reverseMap<T extends string>(value: Record<T, string>) {
return Object.fromEntries(Object.entries(value).map(([key, path]) => [path, key])) as Record<string, T>;
}
export function defaultWorkspaceTaskQuery(): WorkspaceTaskQuery {
return {
query: '',
modelType: '',
createdFrom: '',
createdTo: '',
page: 1,
pageSize: 10,
};
}
export function normalizeWorkspaceTaskQuery(query: WorkspaceTaskQuery): WorkspaceTaskQuery {
return {
query: query.query.trim(),
modelType: query.modelType.trim(),
createdFrom: query.createdFrom.trim(),
createdTo: query.createdTo.trim(),
page: positiveInt(query.page, 1),
pageSize: clampPageSize(query.pageSize),
};
}
export function workspaceTaskQueryKey(query: WorkspaceTaskQuery) {
const normalized = normalizeWorkspaceTaskQuery(query);
return JSON.stringify(normalized);
}
function parseWorkspaceTaskQuery(search: URLSearchParams): WorkspaceTaskQuery {
return normalizeWorkspaceTaskQuery({
query: search.get('q') ?? search.get('query') ?? '',
modelType: search.get('type') ?? search.get('modelType') ?? '',
createdFrom: search.get('from') ?? search.get('createdFrom') ?? '',
createdTo: search.get('to') ?? search.get('createdTo') ?? '',
page: Number(search.get('page') ?? 1),
pageSize: Number(search.get('pageSize') ?? search.get('limit') ?? 10),
});
}
function positiveInt(value: number, fallback: number) {
return Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
}
function clampPageSize(value: number) {
const normalized = positiveInt(value, 10);
return Math.min(100, Math.max(1, normalized));
}
+175 -21
View File
@@ -34,14 +34,14 @@
--color-text-strong: var(--text-strong);
--color-text-normal: var(--text-normal);
--color-text-soft: var(--text-soft);
--text-xs: 12px;
--text-sm: 13px;
--text-base: 14px;
--text-md: 15px;
--text-lg: 16px;
--text-xl: 18px;
--text-2xl: 22px;
--text-3xl: 30px;
--text-xs: 0.75rem;
--text-sm: 0.8125rem;
--text-base: 0.875rem;
--text-md: 0.9375rem;
--text-lg: 1rem;
--text-xl: 1.125rem;
--text-2xl: 1.375rem;
--text-3xl: 1.875rem;
--radius-sm: 7px;
--radius-md: 8px;
--radius-lg: 10px;
@@ -144,7 +144,7 @@ p {
h1 {
color: var(--text-strong);
font-size: 28px;
font-size: 1.75rem;
font-weight: var(--font-weight-semibold);
line-height: 1.15;
}
@@ -163,7 +163,7 @@ strong {
.eyebrow {
margin-bottom: 5px;
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
}
@@ -224,21 +224,162 @@ strong {
gap: 14px;
}
.taskList {
.taskRecordFilters {
grid-template-columns: minmax(240px, 1.4fr) minmax(150px, 0.65fr) minmax(330px, 1.2fr) auto;
gap: 10px;
}
.taskRecordRangeLabel {
min-width: 0;
}
.taskRecordViewport {
--sh-table-viewport-gap: 10px;
--sh-table-viewport-height: calc(100dvh - 90px);
margin-bottom: -52px;
}
.taskRecordSearchBox {
display: grid;
gap: 14px;
}
.taskRecordHeader {
display: flex;
flex-wrap: wrap;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
gap: 8px 12px;
color: var(--muted-foreground);
gap: 8px;
min-height: 32px;
padding-left: 10px;
border: 1px solid var(--input);
border-radius: var(--control-radius);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
}
.taskRecordHeader strong {
.taskRecordSearchBox svg {
color: var(--text-soft);
}
.taskRecordSearchBox .shInput {
border: 0;
box-shadow: none;
}
.taskRecordTable .shTableRow {
grid-template-columns: minmax(190px, 0.95fr) minmax(220px, 1.05fr) minmax(94px, 0.42fr) minmax(280px, 1.55fr) minmax(126px, 0.58fr) minmax(150px, 0.7fr) minmax(154px, 0.66fr) minmax(82px, 0.38fr) minmax(98px, 0.45fr) minmax(150px, 0.7fr) minmax(130px, 0.58fr);
min-width: 1674px;
align-items: start;
}
.taskRecordPrimaryCell {
display: grid;
min-width: 0;
gap: 3px;
}
.taskRecordPrimaryCell strong,
.taskRecordPrimaryCell small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.taskRecordPrimaryCell strong {
color: var(--text-strong);
font-weight: var(--font-weight-semibold);
}
.taskRecordPrimaryCell small {
color: var(--text-soft);
font-size: var(--font-size-xs);
}
.taskRecordIdentityCell {
gap: 6px;
}
.taskRecordIdLine,
.taskRecordRequestLine {
display: grid;
min-width: 0;
align-items: center;
gap: 6px;
}
.taskRecordIdLine {
grid-template-columns: 28px minmax(0, 1fr);
}
.taskRecordRequestLine {
grid-template-columns: minmax(0, 1fr) 28px;
}
.taskRecordIdLine > span {
color: var(--text-soft);
font-size: var(--font-size-xs);
}
.taskRecordIdLine code,
.taskRecordRequestLine code {
overflow: hidden;
min-width: 0;
color: var(--text-normal);
font-family: var(--font-mono);
font-size: var(--font-size-xs);
text-overflow: ellipsis;
white-space: nowrap;
}
.taskRecordRequestLine .shButton {
width: 28px;
min-height: 28px;
}
.taskRecordTokenCell {
overflow: visible;
white-space: normal;
}
.taskRecordTokenUsage {
display: grid;
gap: 2px;
line-height: 1.3;
}
.taskRecordModelCell {
overflow: visible;
white-space: normal;
}
.taskRecordModelCell .taskRecordPrimaryCell strong,
.taskRecordModelCell .taskRecordPrimaryCell small {
overflow: visible;
text-overflow: clip;
white-space: normal;
word-break: break-word;
}
.taskRecordJsonButton {
width: 100%;
justify-content: flex-start;
}
.taskJsonDialog {
width: min(920px, 100%);
}
.taskJsonDialogBody {
grid-template-columns: minmax(0, 1fr);
padding: 0;
}
.taskJsonPreview {
overflow: auto;
max-height: min(620px, calc(100vh - 188px));
margin: 0;
padding: 16px 18px;
background: var(--surface-subtle);
color: var(--text-normal);
font-family: var(--font-mono);
font-size: 0.75rem;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
}
.appShell {
@@ -465,7 +606,7 @@ strong {
display: block;
margin-top: 10px;
color: var(--text-strong);
font-size: 24px;
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
}
@@ -526,4 +667,17 @@ strong {
.tokenInline input {
width: 100%;
}
.taskRecordFilters {
grid-template-columns: 1fr;
}
.shTableFooter {
align-items: flex-start;
flex-direction: column;
}
.shTablePageActions {
flex-wrap: wrap;
}
}
+4 -4
View File
@@ -30,7 +30,7 @@
.docsBrand {
margin-bottom: 18px;
font-size: 17px;
font-size: 1.0625rem;
}
.docsSearch {
@@ -56,7 +56,7 @@
.docsGroup h3 {
margin-bottom: 4px;
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
}
.docsGroup button {
@@ -129,7 +129,7 @@
padding: 13px 16px;
border-bottom: 1px solid #f0f2f5;
color: var(--text-soft);
font-size: 13px;
font-size: 0.8125rem;
}
.paramRow em {
@@ -162,7 +162,7 @@
padding: 12px;
border-radius: 8px;
background: #f7f8fa;
font-size: 12px;
font-size: 0.75rem;
}
@media (max-width: 1180px) {
+11 -11
View File
@@ -21,7 +21,7 @@
.releaseNotice span {
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
}
.landingHero {
@@ -43,14 +43,14 @@
.landingCopy h1 {
max-width: 720px;
font-size: 48px;
font-size: 3rem;
line-height: 1.08;
}
.landingCopy p {
max-width: 660px;
color: var(--text-soft);
font-size: 16px;
font-size: 1rem;
line-height: 1.75;
}
@@ -102,12 +102,12 @@
.previewGrid span {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.previewGrid strong {
font-size: 15px;
font-size: 0.9375rem;
}
.previewFlow {
@@ -115,7 +115,7 @@
border-radius: 10px;
background: var(--primary);
color: #fff;
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
@@ -134,7 +134,7 @@
}
.landingSectionHeader h2 {
font-size: 22px;
font-size: 1.375rem;
}
.coverageGrid,
@@ -161,18 +161,18 @@
.landingFeatureCard span,
.advantageCard p {
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
line-height: 1.6;
}
.landingFeatureCard strong {
display: block;
margin: 8px 0;
font-size: 18px;
font-size: 1.125rem;
}
.advantageCard strong {
font-size: 17px;
font-size: 1.0625rem;
}
.loginRequiredPage {
@@ -208,7 +208,7 @@
}
.landingCopy h1 {
font-size: 34px;
font-size: 2.125rem;
}
.coverageGrid,
+243 -73
View File
@@ -47,7 +47,7 @@
.filterGroup h3 {
color: #1f2937;
font-size: 14px;
font-size: 0.875rem;
}
.filterChips {
@@ -57,22 +57,61 @@
}
.filterChip {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 28px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 999px;
background: #fff;
color: var(--text-normal);
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.filterChip em {
color: var(--text-soft);
font-size: 0.6875rem;
font-style: normal;
font-weight: var(--font-weight-semibold);
}
.filterChipIcon {
display: grid;
width: 18px;
height: 18px;
flex: 0 0 18px;
place-items: center;
overflow: hidden;
border-radius: 50%;
background: var(--surface-subtle);
color: var(--text-soft);
font-size: 0.5625rem;
line-height: 1;
}
.filterChipIcon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.filterChip[data-active="true"] {
border-color: var(--primary);
background: var(--primary);
color: #fff;
}
.filterChip[data-active="true"] em {
color: rgba(255, 255, 255, 0.76);
}
.filterChip[data-active="true"] .filterChipIcon {
background: rgba(255, 255, 255, 0.18);
color: #fff;
}
.modelsContent {
display: grid;
min-width: 0;
@@ -143,65 +182,173 @@
.modelCards {
display: grid;
grid-template-columns: repeat(3, minmax(260px, 1fr));
gap: 14px;
gap: 10px;
}
.modelCard .shCardContent {
display: grid;
min-height: 150px;
gap: 13px;
align-content: start;
min-height: 265px;
gap: 10px;
padding: 14px;
}
.modelCardTop {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) auto;
gap: 11px;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
align-items: start;
}
.modelCardTop strong,
.modelCardTop span,
.modelCard p {
.modelCardTop .modelIcon {
width: 38px;
height: 38px;
}
.modelCardTop .modelIconImage {
padding: 6px;
}
.modelCardHeaderText {
display: grid;
min-width: 0;
gap: 4px;
}
.modelCardTop strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelCardTop span,
.modelCard p,
.modelCardFooter span {
color: var(--muted-foreground);
font-size: 12px;
.modelCardIntro {
display: grid;
align-content: start;
gap: 7px;
}
.modelCardDescription {
display: -webkit-box;
margin: 0;
overflow: hidden;
color: var(--text-soft);
line-height: 1.48;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.modelTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: flex-start;
align-items: flex-start;
gap: 5px;
}
.modelTags span {
padding: 4px 7px;
border: 1px solid #eceff3;
border-radius: 999px;
background: var(--surface-subtle);
color: var(--text-soft);
font-size: 12px;
}
.modelCardFooter {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: auto;
}
.modelCardFooter a {
color: var(--text-strong);
font-size: 12px;
flex: 0 0 auto;
padding: 2px 6px;
border: 1px solid rgba(37, 99, 235, 0.32);
border-radius: 6px;
background: rgba(37, 99, 235, 0.06);
color: #1d4ed8;
font-weight: var(--font-weight-semibold);
text-decoration: none;
}
.modelTags span:nth-child(4n + 2) {
border-color: rgba(5, 150, 105, 0.32);
background: rgba(5, 150, 105, 0.07);
color: #047857;
}
.modelTags span:nth-child(4n + 3) {
border-color: rgba(124, 58, 237, 0.32);
background: rgba(124, 58, 237, 0.07);
color: #6d28d9;
}
.modelTags span:nth-child(4n + 4) {
border-color: rgba(217, 119, 6, 0.32);
background: rgba(217, 119, 6, 0.07);
color: #b45309;
}
.modelCardFacts {
display: grid;
grid-template-columns: minmax(0, 1.45fr) minmax(0, 0.55fr);
gap: 7px 10px;
margin: 0;
padding: 0.625rem;
border-radius: 0.5rem;
background: var(--surface-subtle);
}
.modelCardFacts div {
min-width: 0;
}
.modelCardFacts dt {
margin-bottom: 2px;
color: var(--muted-foreground);
}
.modelCardFacts dd {
margin: 0;
color: var(--text-normal);
font-weight: var(--font-weight-semibold);
line-height: 1.35;
word-break: break-word;
}
.modelCardFactRateLimit dd {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
word-break: normal;
}
.modelCardFacts .modelCardFactFull {
grid-column: 1 / -1;
}
.modelPermissionTags {
display: flex;
flex-wrap: wrap;
gap: 0.1875rem;
}
.modelPermissionTag {
display: inline-flex;
align-items: center;
max-width: 100%;
gap: 0.1875rem;
padding: 0.0625rem 0.3125rem;
border-radius: 999rem;
line-height: 1.2;
}
.modelPermissionTag span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modelPermissionTagAllow {
background: rgba(5, 150, 105, 0.1);
color: #047857;
}
.modelPermissionTagDeny {
background: rgba(220, 38, 38, 0.1);
color: #b91c1c;
}
.modelPermissionIcon {
width: 0.625rem;
height: 0.625rem;
flex: 0 0 auto;
stroke-width: 2.5;
}
.providerToolbar {
@@ -214,7 +361,7 @@
.providerToolbar p {
margin: 0;
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
}
.inlineActions {
@@ -511,7 +658,7 @@
.formMessage {
margin-top: 12px;
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
}
@@ -569,7 +716,7 @@
.providerCatalogBody span,
.providerCatalogMeta {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.providerCatalogMeta,
@@ -625,7 +772,7 @@
.baseModelCardBody > div:first-child span {
margin-top: 4px;
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.baseModelCard .providerCatalogActions {
@@ -644,7 +791,7 @@
border-radius: 999px;
background: var(--surface-subtle);
color: #4b5563;
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
@@ -680,7 +827,7 @@
.baseModelForm textarea {
min-height: 124px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
font-size: 0.75rem;
}
.platformGrid {
@@ -716,7 +863,7 @@
.platformCardBody > div:first-child span,
.platformCardBody p {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.platformCardBody p {
@@ -833,7 +980,7 @@
.platformEmptyState strong {
color: var(--text-strong);
font-size: 18px;
font-size: 1.125rem;
}
.platformEmptyState span {
@@ -872,7 +1019,7 @@
.platformModelRow span {
overflow: hidden;
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -942,12 +1089,12 @@
.platformToggle strong {
color: var(--text-normal);
font-size: 13px;
font-size: 0.8125rem;
}
.platformToggle small {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.platformReadonlyField {
@@ -955,7 +1102,7 @@
place-items: center start;
padding: 0 11px;
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
font-weight: var(--font-weight-semibold);
}
@@ -975,7 +1122,7 @@
overflow: hidden;
color: var(--text-normal);
font-family: var(--font-sans);
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
text-overflow: ellipsis;
white-space: nowrap;
@@ -983,7 +1130,7 @@
.platformCredentialField small {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-normal);
line-height: 1.4;
}
@@ -1022,12 +1169,12 @@
.platformModelSelectorHeader strong {
color: var(--text-strong);
font-size: 14px;
font-size: 0.875rem;
}
.platformModelSelectorHeader span {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.platformSegmented {
@@ -1059,8 +1206,8 @@
.platformModelChoices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
max-height: 310px;
gap: 0.625rem;
max-height: 19.375rem;
overflow: auto;
}
@@ -1070,39 +1217,54 @@
.platformModelChoice {
display: grid;
grid-template-columns: minmax(0, 1fr) 112px auto;
gap: 10px;
grid-template-columns: minmax(10rem, 1fr) minmax(14rem, 1.4fr) auto;
gap: 0.625rem;
align-items: center;
padding: 12px;
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 8px;
border-radius: 0.5rem;
background: #fff;
}
.platformModelChoiceMain {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
gap: 0.625rem;
align-items: start;
min-width: 0;
}
.platformModelChoice input {
margin-top: 3px;
margin-top: 0.1875rem;
accent-color: var(--text-strong);
}
.platformModelChoice > .shInput {
.platformModelChoiceFields {
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5rem;
gap: 0.5rem;
align-items: end;
min-width: 0;
}
.platformModelChoiceFields label {
gap: 0.25rem;
color: var(--muted-foreground);
font-size: 0.75rem;
min-width: 0;
}
.platformModelChoiceFields .shInput {
margin-top: 0;
min-height: 34px;
padding: 0 9px;
font-size: 12px;
min-height: 2rem;
padding: 0 0.5625rem;
font-size: 0.75rem;
}
.platformModelChoiceMain span {
display: grid;
min-width: 0;
gap: 4px;
gap: 0.25rem;
}
.platformModelChoiceMain strong,
@@ -1115,7 +1277,7 @@
.platformModelChoiceMain small,
.platformModelEmpty {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.platformModelEmpty {
@@ -1164,12 +1326,12 @@
.modelPickerHeader strong {
color: var(--text-strong);
font-size: 15px;
font-size: 0.9375rem;
}
.modelPickerHeader span {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.modelPickerToolbar {
@@ -1242,12 +1404,12 @@
.modelPickerItem strong {
color: var(--text-strong);
font-size: 13px;
font-size: 0.8125rem;
}
.modelPickerItem small {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.modelPickerActions {
@@ -1295,7 +1457,7 @@
.runtimePolicyCard header span,
.runtimePolicyCard p {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.runtimePolicySummary {
@@ -1312,7 +1474,7 @@
border-radius: 8px;
background: var(--surface-subtle);
color: var(--text-normal);
font-size: 12px;
font-size: 0.75rem;
white-space: nowrap;
}
@@ -1348,7 +1510,7 @@
.runtimePolicySection header span {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.runtimePolicyRows {
@@ -1401,6 +1563,14 @@
grid-template-columns: 1fr;
}
.platformModelChoice {
grid-template-columns: 1fr auto;
}
.platformModelChoiceFields {
grid-column: 1 / -1;
}
.providerCatalogCard {
grid-template-columns: 48px minmax(0, 1fr);
}
+1 -1
View File
@@ -1166,7 +1166,7 @@
.mediaTaskTimeline h1 {
width: min(1240px, 100%);
font-size: 30px;
font-size: 1.875rem;
}
.mediaTaskTimeline > .playgroundError {
+10 -10
View File
@@ -29,7 +29,7 @@
.pricingRuleCard p {
margin: 0;
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.pricingRuleItems {
@@ -47,7 +47,7 @@
border: 1px solid var(--border-subtle);
background: var(--surface-subtle);
color: #4b5563;
font-size: 12px;
font-size: 0.75rem;
}
.pricingRuleItems strong {
@@ -55,7 +55,7 @@
align-items: center;
gap: 6px;
color: var(--text-strong);
font-size: 13px;
font-size: 0.8125rem;
}
.pricingRuleItems svg {
@@ -96,13 +96,13 @@
.pricingModeRule strong,
.pricingWeightTable strong {
display: block;
font-size: 14px;
font-size: 0.875rem;
}
.pricingModeHeader span,
.pricingModeRule header span {
color: var(--muted-foreground);
font-size: 12px;
font-size: 0.75rem;
}
.pricingModeTabs {
@@ -119,7 +119,7 @@
border-bottom: 3px solid transparent;
background: transparent;
color: var(--text-soft);
font-size: 14px;
font-size: 0.875rem;
font-weight: var(--font-weight-semibold);
}
@@ -313,7 +313,7 @@
border-right: 1px solid var(--border);
background: var(--surface-subtle);
color: var(--text-normal);
font-size: 12px;
font-size: 0.75rem;
font-weight: var(--font-weight-medium);
line-height: 1.35;
}
@@ -332,7 +332,7 @@
align-items: center;
padding: 0 12px;
color: var(--text-strong);
font-size: 13px;
font-size: 0.8125rem;
}
.pricingFormulaBox {
@@ -348,7 +348,7 @@
.pricingFormulaBox p {
margin-top: 8px;
color: var(--muted-foreground);
font-size: 13px;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
}
@@ -397,7 +397,7 @@
border-radius: 8px;
background: var(--surface-subtle);
color: var(--text-normal);
font-size: 13px;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
}
+275 -1
View File
@@ -671,10 +671,250 @@
background: var(--surface);
}
.shDateRangePicker.ant-picker {
min-height: 36px;
padding: 0 10px;
border-color: var(--input);
border-radius: var(--control-radius);
background: var(--surface);
color: var(--text-normal);
font-family: inherit;
font-size: var(--font-size-base);
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.04);
transition: border-color 0.16s ease, box-shadow 0.16s ease;
}
.shDateRangePicker.ant-picker:hover,
.shDateRangePicker.ant-picker-focused {
border-color: var(--ring);
box-shadow: 0 0 0 1px var(--ring);
}
.shDateRangePicker.ant-picker-disabled {
background: var(--surface-muted);
opacity: 0.64;
}
.shDateRangePicker .ant-picker-input > input {
color: var(--text-normal);
font-family: inherit;
font-size: var(--font-size-base);
font-weight: var(--font-weight-regular);
}
.shDateRangePicker .ant-picker-input > input::placeholder {
color: var(--muted-foreground);
font-weight: var(--font-weight-regular);
}
.shDateRangePicker .ant-picker-active-bar {
background: var(--primary);
}
.shDateRangePicker .ant-picker-range-separator,
.shDateRangePicker .ant-picker-suffix,
.shDateRangePicker .ant-picker-clear {
color: var(--text-soft);
}
.shDateRangePicker .ant-picker-clear {
background: var(--surface);
}
.shDateRangePickerPopup {
font-family: inherit;
}
.shDateRangePickerPopup .ant-picker-panel-container {
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--popover);
color: var(--popover-foreground);
box-shadow: var(--shadow-dialog);
}
.shDateRangePickerPopup .ant-picker-panel,
.shDateRangePickerPopup .ant-picker-date-panel,
.shDateRangePickerPopup .ant-picker-time-panel,
.shDateRangePickerPopup .ant-picker-footer,
.shDateRangePickerPopup .ant-picker-content th,
.shDateRangePickerPopup .ant-picker-header {
border-color: var(--border-subtle);
}
.shDateRangePickerPopup .ant-picker-header,
.shDateRangePickerPopup .ant-picker-content th,
.shDateRangePickerPopup .ant-picker-cell,
.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell .ant-picker-time-panel-cell-inner {
color: var(--text-soft);
font-family: inherit;
font-size: var(--font-size-sm);
}
.shDateRangePickerPopup .ant-picker-header-view,
.shDateRangePickerPopup .ant-picker-header button,
.shDateRangePickerPopup .ant-picker-cell-in-view,
.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell-selected .ant-picker-time-panel-cell-inner {
color: var(--text-strong);
}
.shDateRangePickerPopup .ant-picker-cell-inner {
border-radius: var(--control-radius);
}
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-today .ant-picker-cell-inner::before {
border-color: var(--ring);
border-radius: var(--control-radius);
}
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner,
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-start .ant-picker-cell-inner,
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-end .ant-picker-cell-inner {
background: var(--primary);
color: var(--primary-foreground);
}
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-in-range::before,
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-start:not(.ant-picker-cell-range-start-single)::before,
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-end:not(.ant-picker-cell-range-end-single)::before {
background: var(--surface-muted);
}
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-hover::before,
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-hover-start::before,
.shDateRangePickerPopup .ant-picker-cell-in-view.ant-picker-cell-range-hover-end::before {
background: var(--accent);
}
.shDateRangePickerPopup .ant-picker-cell:hover .ant-picker-cell-inner,
.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell .ant-picker-time-panel-cell-inner:hover {
background: var(--accent);
}
.shDateRangePickerPopup .ant-picker-time-panel-column > li.ant-picker-time-panel-cell-selected .ant-picker-time-panel-cell-inner {
background: var(--surface-muted);
font-weight: var(--font-weight-semibold);
}
.shDateRangePickerPopup .ant-picker-ok .ant-btn-primary {
border-color: var(--primary);
border-radius: var(--control-radius);
background: var(--primary);
color: var(--primary-foreground);
box-shadow: none;
}
.shDateRangePickerPopup .ant-picker-now-btn {
color: var(--text-normal);
}
.shTable {
overflow: auto;
}
.shTableViewportCard {
display: grid;
max-height: var(--sh-table-viewport-height, calc(100dvh - 142px));
min-height: 0;
}
.shTableViewportPanel {
display: flex;
min-height: 0;
max-height: var(--sh-table-viewport-height, calc(100dvh - 142px));
flex-direction: column;
gap: var(--sh-table-viewport-gap, 14px);
overflow: hidden;
}
.shTableViewportPanel > .formMessage {
margin: 0;
}
.shTableViewportLayout {
display: flex;
min-height: 0;
max-height: 100%;
flex: 0 1 auto;
flex-direction: column;
gap: var(--sh-table-viewport-gap, 14px);
overflow: hidden;
}
.shTableToolbar {
display: grid;
flex: 0 0 auto;
align-items: end;
gap: 12px;
}
.shTableSummary,
.shTableFooter,
.shTablePageActions {
display: flex;
align-items: center;
gap: 10px;
}
.shTableFooter {
flex: 0 0 auto;
flex-wrap: wrap;
}
.shTableFooterGroup {
display: flex;
min-width: 0;
align-items: center;
gap: 10px;
}
.shTableSummary,
.shTableFooter {
justify-content: space-between;
color: var(--text-soft);
font-size: var(--font-size-sm);
}
.shTableFooter .shLabel {
width: 116px;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
}
.shTablePageJump {
display: flex;
align-items: center;
gap: 6px;
}
.shTableFooterGroup,
.shTablePageJump,
.shTablePageActions {
flex-wrap: wrap;
}
.shTablePageJump .shInput {
width: 58px;
text-align: center;
}
.shTableViewport {
flex: 0 1 auto;
min-height: 0;
overflow: auto;
border: 1px solid var(--border-subtle);
border-radius: var(--radius-md);
background: var(--surface);
overscroll-behavior: contain;
}
.shTableViewport .shTableHeader {
position: sticky;
top: 0;
z-index: 1;
}
.shTableRow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
@@ -706,6 +946,39 @@
font-size: var(--font-size-sm);
}
.shTableCompact .shTableHead,
.shTableCompact .shTableCell {
min-height: 30px;
padding: 5px 9px;
font-size: var(--font-size-xs);
line-height: 1.3;
}
.shTableCompact .shTableHead {
font-weight: var(--font-weight-semibold);
}
.shTableCompact .shTableRow {
font-size: var(--font-size-xs);
}
.shTableCompact .shBadge {
min-height: 18px;
padding: 0 6px;
font-size: 0.6875rem;
}
.shTableCompact .shButtonSm {
min-height: 24px;
padding: 0 8px;
font-size: var(--font-size-xs);
}
.shTableCompact .shButtonIcon {
width: 24px;
min-height: 24px;
}
.emptyState {
display: grid;
min-height: 104px;
@@ -726,7 +999,7 @@
background: var(--surface-subtle);
color: var(--text-normal);
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 12px;
font-size: 0.75rem;
}
.modelMeta code,
@@ -750,4 +1023,5 @@
.formDialogBody {
grid-template-columns: 1fr;
}
}
+11
View File
@@ -43,6 +43,15 @@ export interface ApiKeyForm {
expiresAt: string;
}
export interface WorkspaceTaskQuery {
query: string;
modelType: string;
createdFrom: string;
createdTo: string;
page: number;
pageSize: number;
}
export interface PlatformForm {
provider: string;
platformKey: string;
@@ -56,6 +65,7 @@ export interface PlatformModelForm {
platformId: string;
canonicalModelKey: string;
modelName: string;
providerModelName: string;
modelAlias: string;
modelType: string[];
pricingRuleSetId: string;
@@ -83,6 +93,7 @@ export interface PlatformModelBindingInput {
canonicalModelKey?: string;
baseModelId?: string;
modelName: string;
providerModelName?: string;
modelAlias?: string;
modelType: string[];
displayName?: string;