feat: add priority demotion controls

This commit is contained in:
2026-05-12 18:43:20 +08:00
parent 3917b84b5d
commit 98abd247d6
22 changed files with 917 additions and 85 deletions
+35
View File
@@ -19,6 +19,7 @@ import type {
IntegrationPlatform,
ModelCatalogResponse,
ModelRateLimitStatus,
PlatformDynamicPriorityUpdateRequest,
PlatformModel,
PricingRule,
PricingRuleSet,
@@ -79,6 +80,7 @@ import {
updateAccessRule,
updateGatewayUser,
updatePlatform,
updatePlatformDynamicPriority,
updateTenant,
updateUserGroup,
} from './api';
@@ -558,6 +560,38 @@ export function App() {
}
}
async function savePlatformDynamicPriority(platformId: string, input: PlatformDynamicPriorityUpdateRequest) {
setCoreState('loading');
setCoreMessage('');
try {
const state = await updatePlatformDynamicPriority(token, platformId, input);
setPlatforms((current) => current.map((platform) => platform.id === platformId
? {
...platform,
dynamicPriority: state.dynamicPriority,
effectivePriority: state.effectivePriority,
priority: state.priority,
updatedAt: state.updatedAt,
}
: platform));
setModelRateLimits((current) => current.map((status) => status.platformId === platformId
? {
...status,
platformDynamicPriority: state.dynamicPriority,
platformEffectivePriority: state.effectivePriority,
platformPriority: state.priority,
}
: status));
invalidateDataKeys('modelCatalog', 'modelRateLimits', 'platforms', 'playgroundModels');
setCoreState('ready');
setCoreMessage(input.reset ? '平台动态优先级已重置。' : '平台动态优先级已更新。');
} catch (err) {
setCoreState('error');
setCoreMessage(err instanceof Error ? err.message : '更新平台动态优先级失败');
throw err;
}
}
async function removePlatform(platformId: string) {
setCoreState('loading');
setCoreMessage('');
@@ -994,6 +1028,7 @@ export function App() {
onResetAllBaseModels={resetAllBaseModelsToDefault}
onResetBaseModel={resetBaseModelToDefault}
onSavePlatform={savePlatformWithModels}
onSavePlatformDynamicPriority={savePlatformDynamicPriority}
onSaveProvider={saveProvider}
onSavePricingRuleSet={savePricingRuleSet}
onSaveRunnerPolicy={saveRunnerPolicy}
+14
View File
@@ -24,6 +24,8 @@ import type {
ListResponse,
ModelCatalogResponse,
ModelRateLimitStatus,
PlatformDynamicPriorityState,
PlatformDynamicPriorityUpdateRequest,
PlatformModel,
PlayableGatewayApiKey,
PricingRule,
@@ -102,6 +104,18 @@ export async function listPlatforms(token: string): Promise<ListResponse<Integra
return request<ListResponse<IntegrationPlatform>>('/api/admin/platforms', { token });
}
export async function updatePlatformDynamicPriority(
token: string,
platformId: string,
input: PlatformDynamicPriorityUpdateRequest,
): Promise<PlatformDynamicPriorityState> {
return request<PlatformDynamicPriorityState>(`/api/admin/platforms/${platformId}/dynamic-priority`, {
body: input,
method: 'PATCH',
token,
});
}
export async function listModels(token: string): Promise<ListResponse<PlatformModel>> {
return request<ListResponse<PlatformModel>>('/api/admin/models', { token });
}
+3
View File
@@ -8,6 +8,7 @@ import type {
GatewayTenantUpsertRequest,
GatewayRunnerPolicyUpsertRequest,
GatewayUserUpsertRequest,
PlatformDynamicPriorityUpdateRequest,
PricingRuleSetUpsertRequest,
RuntimePolicySetUpsertRequest,
UserGroupUpsertRequest,
@@ -63,6 +64,7 @@ export function AdminPage(props: {
onResetBaseModel: (baseModelId: string) => Promise<void>;
onBatchAccessRules: (input: GatewayAccessRuleBatchRequest) => Promise<void>;
onSavePlatform: (input: PlatformWithModelsInput) => Promise<void>;
onSavePlatformDynamicPriority: (platformId: string, input: PlatformDynamicPriorityUpdateRequest) => Promise<void>;
onSaveProvider: (input: CatalogProviderUpsertRequest, providerId?: string) => Promise<void>;
onSavePricingRuleSet: (input: PricingRuleSetUpsertRequest, ruleSetId?: string) => Promise<void>;
onSaveRunnerPolicy: (input: GatewayRunnerPolicyUpsertRequest) => Promise<void>;
@@ -154,6 +156,7 @@ export function AdminPage(props: {
modelRateLimits={props.data.modelRateLimits}
modelRateLimitsUpdatedAt={props.data.modelRateLimitsUpdatedAt}
platforms={props.data.platforms}
onSavePlatformDynamicPriority={props.onSavePlatformDynamicPriority}
/>
)}
{props.section === 'tenants' && <TenantsPanel {...identityPanelProps(props)} />}
+313 -10
View File
@@ -1,14 +1,19 @@
import { useEffect, useMemo, useState } from 'react';
import { Gauge } from 'lucide-react';
import type { IntegrationPlatform, ModelRateLimitStatus } from '@easyai-ai-gateway/contracts';
import { Badge, Card, CardContent, CardHeader, CardTitle, EmptyState, Table, TableCell, TableHead, TableRow } from '../../components/ui';
import { useEffect, useMemo, useState, type FormEvent } from 'react';
import { Popover as AntPopover } from 'antd';
import { CheckCircle2, Gauge, History, RotateCcw, SlidersHorizontal } from 'lucide-react';
import type { IntegrationPlatform, ModelRateLimitStatus, PlatformDynamicPriorityUpdateRequest, PriorityDemotionRecord } from '@easyai-ai-gateway/contracts';
import { Badge, Button, Card, CardContent, CardHeader, CardTitle, EmptyState, FormDialog, Input, Label, Table, TableCell, TableHead, TableRow } from '../../components/ui';
export function RealtimeLoadPanel(props: {
modelRateLimits: ModelRateLimitStatus[];
modelRateLimitsUpdatedAt: number | null;
platforms: IntegrationPlatform[];
onSavePlatformDynamicPriority: (platformId: string, input: PlatformDynamicPriorityUpdateRequest) => Promise<void>;
}) {
const [now, setNow] = useState(() => Date.now());
const [priorityDialog, setPriorityDialog] = useState<PriorityDialogState | null>(null);
const [priorityError, setPriorityError] = useState('');
const [prioritySaving, setPrioritySaving] = useState(false);
const platformMap = useMemo(() => new Map(props.platforms.map((item) => [item.id, item])), [props.platforms]);
useEffect(() => {
@@ -16,6 +21,50 @@ export function RealtimeLoadPanel(props: {
return () => window.clearInterval(timer);
}, []);
function openPriorityDialog(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) {
setPriorityError('');
setPriorityDialog({
platform,
status,
value: formatPriority(platformEffectivePriority(status, platform)),
});
}
function closePriorityDialog() {
if (prioritySaving) return;
setPriorityDialog(null);
setPriorityError('');
}
async function submitPriorityForm(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!priorityDialog) return;
const dynamicPriority = parsePriorityInput(priorityDialog.value);
if (dynamicPriority === null) {
setPriorityError('请输入大于等于 0 的整数。');
return;
}
await savePriority(priorityDialog.status.platformId, { dynamicPriority });
}
async function resetPriority() {
if (!priorityDialog) return;
await savePriority(priorityDialog.status.platformId, { reset: true });
}
async function savePriority(platformId: string, input: PlatformDynamicPriorityUpdateRequest) {
setPrioritySaving(true);
setPriorityError('');
try {
await props.onSavePlatformDynamicPriority(platformId, input);
setPriorityDialog(null);
} catch (err) {
setPriorityError(err instanceof Error ? err.message : '更新平台动态优先级失败');
} finally {
setPrioritySaving(false);
}
}
return (
<section className="pageStack">
<Card>
@@ -31,14 +80,36 @@ export function RealtimeLoadPanel(props: {
platformMap={platformMap}
statuses={props.modelRateLimits}
updatedAt={props.modelRateLimitsUpdatedAt}
onAdjustPriority={openPriorityDialog}
/>
</CardContent>
</Card>
<PlatformPriorityDialog
dialog={priorityDialog}
error={priorityError}
saving={prioritySaving}
onClose={closePriorityDialog}
onReset={resetPriority}
onSubmit={submitPriorityForm}
onValueChange={(value) => setPriorityDialog((current) => current ? { ...current, value } : current)}
/>
</section>
);
}
function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platformMap: Map<string, IntegrationPlatform>; now: number; updatedAt: number | null }) {
type PriorityDialogState = {
platform: IntegrationPlatform | undefined;
status: ModelRateLimitStatus;
value: string;
};
function RateLimitStatusTable(props: {
statuses: ModelRateLimitStatus[];
platformMap: Map<string, IntegrationPlatform>;
now: number;
updatedAt: number | null;
onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void;
}) {
if (!props.statuses.length) {
return <EmptyState title="暂无实时负载" description="模型产生请求后会在这里显示实时 RPM、TPM 和并发窗口。" />;
}
@@ -53,6 +124,8 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
<TableRow className="shTableHeader">
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="platformLimitNumberHead"></TableHead>
<TableHead className="platformLimitNumberHead"></TableHead>
<TableHead className="platformLimitMetricHead platformLimitNumberHead" title="正在执行 / 并发上限 / 排队任务">
<span></span>
<small> / / </small>
@@ -60,7 +133,6 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
<TableHead className="platformLimitNumberHead">TPM</TableHead>
<TableHead className="platformLimitNumberHead">RPM</TableHead>
<TableHead className="platformLimitStatusHead"></TableHead>
<TableHead className="platformLimitNumberHead"></TableHead>
</TableRow>
{props.statuses.map((status) => {
const platform = props.platformMap.get(status.platformId);
@@ -78,16 +150,17 @@ function RateLimitStatusTable(props: { statuses: ModelRateLimitStatus[]; platfor
<small>{status.provider}</small>
</span>
</TableCell>
<TableCell className="platformLimitNumberCell">{concurrencyMetricCell(status)}</TableCell>
<TableCell className="platformLimitNumberCell">{metricCell(status.tpm, true)}</TableCell>
<TableCell className="platformLimitNumberCell">{metricCell(status.rpm)}</TableCell>
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, props.now)}</TableCell>
<TableCell className="platformLimitNumberCell">{platformPriorityCell(status, platform, props.onAdjustPriority)}</TableCell>
<TableCell className="platformLimitNumberCell">
<span className="rateLoadCell" data-overloaded={status.loadRatio > 0.8 ? 'true' : undefined}>
<strong>{formatPercent(status.loadRatio)}</strong>
<span className="rateLoadTrack"><i style={{ width: `${Math.min(status.loadRatio * 100, 100)}%` }} /></span>
</span>
</TableCell>
<TableCell className="platformLimitNumberCell">{concurrencyMetricCell(status)}</TableCell>
<TableCell className="platformLimitNumberCell">{metricCell(status.tpm, true)}</TableCell>
<TableCell className="platformLimitNumberCell">{metricCell(status.rpm)}</TableCell>
<TableCell className="platformLimitStatusCell">{modelRuntimeStatusCell(status, props.now)}</TableCell>
</TableRow>
);
})}
@@ -101,6 +174,176 @@ function platformDisplayName(platform: IntegrationPlatform) {
return platform.internalName?.trim() || platform.name;
}
function platformPriorityCell(
status: ModelRateLimitStatus,
platform: IntegrationPlatform | undefined,
onAdjustPriority: (status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) => void,
) {
const records = status.recentPriorityDemotions ?? [];
const content = <PriorityDemotionPopover records={records} />;
return (
<span className="platformPriorityCell">
<AntPopover
align={{ offset: [0, 8] }}
content={content}
overlayClassName="priorityDemotionAntPopover"
placement="bottomLeft"
trigger={['hover', 'focus']}
>
<button className="priorityDemotionTrigger" type="button" aria-label={priorityDemotionAriaLabel(status, platform)}>
<span className="rateMetricCell">
<strong>{formatPriority(platformEffectivePriority(status, platform))}</strong>
<small>{platformPrioritySubtitle(status, platform, records.length)}</small>
</span>
</button>
</AntPopover>
<Button className="priorityAdjustButton" type="button" variant="outline" size="xs" onClick={() => onAdjustPriority(status, platform)}>
<SlidersHorizontal size={13} />
</Button>
</span>
);
}
function PlatformPriorityDialog(props: {
dialog: PriorityDialogState | null;
error: string;
saving: boolean;
onClose: () => void;
onReset: () => void;
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
onValueChange: (value: string) => void;
}) {
const dialog = props.dialog;
const platform = dialog?.platform;
const status = dialog?.status;
return (
<FormDialog
bodyClassName="platformPriorityDialogBody"
className="platformPriorityDialog"
closeLabel="关闭"
eyebrow="Runtime Priority"
footer={(
<>
<Button type="button" variant="outline" disabled={props.saving} onClick={props.onClose}>
</Button>
<Button type="button" variant="secondary" disabled={props.saving || !dialog} onClick={props.onReset}>
<RotateCcw size={15} />
</Button>
<Button type="submit" disabled={props.saving || !dialog}>
<CheckCircle2 size={15} />
</Button>
</>
)}
open={Boolean(dialog)}
title="调整平台动态优先级"
onClose={props.onClose}
onSubmit={props.onSubmit}
>
<div className="platformPriorityDialogSummary">
<span>
<strong>{platform ? platformDisplayName(platform) : status?.platformName}</strong>
<small>{status?.provider}</small>
</span>
<span>
<strong>{formatPriority(platformEffectivePriority(status, platform))}</strong>
<small></small>
</span>
</div>
<div className="platformPriorityDialogMetrics">
<span> {formatPriority(platformStaticPriority(status, platform))}</span>
<span> {formatPriority(platformDynamicPriority(status, platform))}</span>
</div>
<Label>
<Input
value={dialog?.value ?? ''}
inputMode="numeric"
min={0}
placeholder="例如 1511"
onChange={(event) => props.onValueChange(event.target.value)}
/>
</Label>
{props.error && <p className="formMessage error">{props.error}</p>}
</FormDialog>
);
}
function PriorityDemotionPopover(props: { records: PriorityDemotionRecord[] }) {
if (!props.records.length) {
return (
<span className="priorityDemotionPopover" role="tooltip">
<span className="priorityDemotionEmpty"></span>
</span>
);
}
return (
<span className="priorityDemotionPopover" role="tooltip">
<span className="priorityDemotionHeader">
<History size={14} />
<strong> 10 </strong>
</span>
{props.records.map((record) => (
<span key={record.id} className="priorityDemotionItem">
<span className="priorityDemotionItemHeader">
<strong>{priorityDemotionReasonText(record)}</strong>
{priorityIsSet(record.dynamicPriority) ? <Badge variant="secondary"> {formatPriority(record.dynamicPriority)}</Badge> : null}
</span>
<small>{priorityDemotionMetaText(record)}</small>
{record.errorMessage && <span className="priorityDemotionError">{record.errorMessage}</span>}
</span>
))}
</span>
);
}
function platformPrioritySubtitle(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined, demotionCount: number) {
const staticPriority = platformStaticPriority(status, platform);
const dynamicPriority = platformDynamicPriority(status, platform);
const base = priorityIsSet(dynamicPriority) ? `静态 ${formatPriority(staticPriority)} · 动态 ${formatPriority(dynamicPriority)}` : `静态 ${formatPriority(staticPriority)}`;
return demotionCount ? `${base} · 降级 ${demotionCount}` : base;
}
function priorityDemotionAriaLabel(status: ModelRateLimitStatus, platform: IntegrationPlatform | undefined) {
const count = status.recentPriorityDemotions?.length ?? 0;
return `平台优先级 ${formatPriority(platformEffectivePriority(status, platform))},最近 ${count} 条优先级降级记录`;
}
function priorityDemotionReasonText(record: PriorityDemotionRecord) {
const category = record.category ? `错误分类 ${record.category}` : '';
const code = record.errorCode ? `错误 ${record.errorCode}` : '';
const statusCode = record.statusCode ? `状态码 ${record.statusCode}` : '';
return [priorityDemotionReasonLabel(record.reason), statusCode, code, category].filter(Boolean).join(' · ');
}
function priorityDemotionMetaText(record: PriorityDemotionRecord) {
const policy = priorityDemotionPolicyText(record);
const values = [
formatDateTime(record.createdAt),
shortId(record.taskId) ? `任务 ${shortId(record.taskId)}` : '',
policy,
].filter(Boolean);
return values.join(' · ') || '-';
}
function priorityDemotionPolicyText(record: PriorityDemotionRecord) {
const policyPath = [record.policySource || record.policy, record.policyRule].filter(Boolean).join('.');
if (!policyPath) return '';
return record.matchedValue ? `策略 ${policyPath}=${record.matchedValue}` : `策略 ${policyPath}`;
}
function priorityDemotionReasonLabel(reason: string | undefined) {
const labels: Record<string, string> = {
priority_demote_policy: '命中优先级降级规则',
hard_stop_policy: '命中硬拒绝规则',
runner_policy_disabled: '全局调度策略停用',
};
return reason ? labels[reason] ?? reason : '优先级降级';
}
function metricCell(metric: ModelRateLimitStatus['rpm'], includeReserved = false) {
if (!metric.limited) return <span className="rateMetricCell"><strong>{formatLimit(metric.currentValue)} / </strong><small>{includeReserved ? reservedMetricText(metric) : '未配置上限'}</small></span>;
return (
@@ -132,6 +375,19 @@ function formatTimeOfDay(timestamp: number | null) {
return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function formatDateTime(value: string | undefined) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const pad = (item: number) => String(item).padStart(2, '0');
return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
}
function shortId(value: string | undefined) {
if (!value) return '';
return value.length > 8 ? value.slice(0, 8) : value;
}
function modelRuntimeStatusCell(status: ModelRateLimitStatus, now: number) {
const modelCooldownMs = cooldownRemainingMs(status.modelCooldownUntil, now);
const platformCooldownMs = cooldownRemainingMs(status.platformCooldownUntil, now);
@@ -184,6 +440,53 @@ function formatLimit(value: number) {
return trimNumber(value);
}
function platformEffectivePriority(status: ModelRateLimitStatus | undefined, platform: IntegrationPlatform | undefined) {
return firstPriority(
status?.platformEffectivePriority,
platform?.effectivePriority,
status?.platformDynamicPriority,
platform?.dynamicPriority,
status?.platformPriority,
platform?.priority,
);
}
function platformStaticPriority(status: ModelRateLimitStatus | undefined, platform: IntegrationPlatform | undefined) {
return firstPriority(status?.platformPriority, platform?.priority);
}
function platformDynamicPriority(status: ModelRateLimitStatus | undefined, platform: IntegrationPlatform | undefined) {
return firstPriority(
status?.platformDynamicPriority,
platform?.dynamicPriority,
status?.platformEffectivePriority,
platform?.effectivePriority,
status?.platformPriority,
platform?.priority,
);
}
function firstPriority(...values: Array<number | undefined>) {
return values.find(priorityIsSet);
}
function priorityIsSet(value: number | undefined): value is number {
return typeof value === 'number' && Number.isFinite(value);
}
function formatPriority(value: number | undefined) {
if (!Number.isFinite(value)) return '-';
return String(Math.trunc(value ?? 0));
}
function parsePriorityInput(value: string) {
const trimmed = value.trim();
if (!/^\d+$/.test(trimmed)) return null;
const parsed = Number(trimmed);
if (!Number.isSafeInteger(parsed)) return null;
return parsed;
}
function trimNumber(value: number) {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, '');
}
@@ -50,7 +50,6 @@ type RunnerPolicyForm = {
hardStopStatusCodes: string[];
hardStopKeywords: string[];
priorityDemoteEnabled: boolean;
priorityDemoteStep: string;
priorityDemoteCategories: string[];
priorityDemoteCodes: string[];
priorityDemoteStatusCodes: string[];
@@ -406,11 +405,7 @@ function RunnerPolicyEditor(props: {
{activeStrategy === 'priorityDemote' && (
<div className="runtimePolicyRows runnerPolicyDetailRows">
<Toggle checked={props.form.priorityDemoteEnabled} label="启用优先级降级" onChange={(checked) => patch({ priorityDemoteEnabled: checked })} />
<Label>
<Input value={props.form.priorityDemoteStep} inputMode="numeric" onChange={(event) => patch({ priorityDemoteStep: event.target.value })} />
<span className="runtimeFieldHint"> 100</span>
</Label>
<span className="runtimeFieldHint spanTwo"></span>
<KeywordField label="降级分类" value={props.form.priorityDemoteCategories} onChange={(value) => patch({ priorityDemoteCategories: value })} />
<KeywordField label="降级错误码" value={props.form.priorityDemoteCodes} onChange={(value) => patch({ priorityDemoteCodes: value })} />
<KeywordField label="降级状态码" value={props.form.priorityDemoteStatusCodes} onChange={(value) => patch({ priorityDemoteStatusCodes: value })} />
@@ -478,7 +473,6 @@ function runnerPolicyToForm(policy: GatewayRunnerPolicy | null): RunnerPolicyFor
hardStopStatusCodes: tagsFromValue(hardStop.statusCodes ?? []),
hardStopKeywords: tagsFromValue(hardStop.keywords ?? ['invalid_parameter', 'missing required', 'bad request', 'insufficient balance']),
priorityDemoteEnabled: readBool(priorityDemote.enabled, true),
priorityDemoteStep: String(readNumber(priorityDemote.demoteStep, 100)),
priorityDemoteCategories: tagsFromValue(priorityDemote.categories ?? ['network', 'timeout', 'stream_error', 'rate_limit', 'provider_5xx', 'provider_overloaded']),
priorityDemoteCodes: tagsFromValue(priorityDemote.codes ?? ['network', 'timeout', 'stream_read_error', 'rate_limit', 'server_error', 'overloaded']),
priorityDemoteStatusCodes: tagsFromValue(priorityDemote.statusCodes ?? [408, 429, 500, 502, 503, 504]),
@@ -516,7 +510,6 @@ function runnerFormToPayload(form: RunnerPolicyForm): GatewayRunnerPolicyUpsertR
},
priorityDemotePolicy: {
enabled: form.priorityDemoteEnabled,
demoteStep: positiveInt(form.priorityDemoteStep, 100),
categories: cleanTags(form.priorityDemoteCategories),
codes: cleanTags(form.priorityDemoteCodes),
statusCodes: parseNumberTags(form.priorityDemoteStatusCodes),
+139 -2
View File
@@ -1016,8 +1016,8 @@
}
.platformLimitTable .shTableRow {
grid-template-columns: minmax(180px, 1.15fr) minmax(160px, 0.95fr) 150px 170px 140px 132px 132px;
min-width: 1064px;
grid-template-columns: minmax(180px, 1.1fr) minmax(160px, 0.9fr) 160px 132px 150px 170px 140px 132px;
min-width: 1224px;
}
.platformLimitTable .shTableHead,
@@ -1113,6 +1113,143 @@
background: var(--destructive);
}
.platformPriorityCell {
display: grid;
width: 100%;
min-width: 0;
gap: 7px;
align-content: start;
}
.platformPriorityCell .rateMetricCell small {
overflow: visible;
line-height: 1.35;
text-overflow: clip;
white-space: normal;
}
.priorityDemotionTrigger {
display: grid;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
cursor: default;
text-align: left;
font: inherit;
}
.priorityAdjustButton {
justify-self: start;
min-height: 22px;
padding-inline: 8px;
border-color: var(--border);
color: var(--text-normal);
background: #fff;
}
.priorityDemotionAntPopover {
z-index: 1200;
}
.priorityDemotionPopover {
display: grid;
width: min(36rem, calc(100vw - 2rem));
gap: 0.6rem;
}
.priorityDemotionHeader,
.priorityDemotionItemHeader {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.priorityDemotionHeader {
color: var(--text-strong);
}
.priorityDemotionHeader strong,
.priorityDemotionItemHeader strong {
min-width: 0;
overflow-wrap: anywhere;
}
.priorityDemotionItem {
display: grid;
gap: 0.35rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--border);
}
.priorityDemotionItem:last-child {
padding-bottom: 0;
border-bottom: 0;
}
.priorityDemotionItem small,
.priorityDemotionEmpty {
color: var(--text-soft);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.priorityDemotionError {
color: var(--destructive);
font-size: var(--font-size-xs);
line-height: 1.45;
overflow-wrap: anywhere;
}
.platformPriorityDialog {
width: min(30rem, calc(100vw - 2rem));
}
.platformPriorityDialogBody {
display: grid;
gap: 1rem;
}
.platformPriorityDialogSummary,
.platformPriorityDialogMetrics {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.platformPriorityDialogSummary {
padding: 0.75rem;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: #f8fafc;
}
.platformPriorityDialogSummary span {
display: grid;
min-width: 0;
gap: 3px;
}
.platformPriorityDialogSummary span:last-child {
justify-items: end;
font-variant-numeric: tabular-nums;
}
.platformPriorityDialogSummary strong {
color: var(--text-strong);
}
.platformPriorityDialogSummary small,
.platformPriorityDialogMetrics {
color: var(--muted-foreground);
font-size: var(--font-size-xs);
}
.platformModelToolbar {
display: grid;
grid-template-columns: minmax(220px, 0.6fr) minmax(260px, 1fr);